@adecore/editor-react 0.39.0 → 0.40.0

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 (154) hide show
  1. package/dist/CallHierarchyPanel.js +30 -55
  2. package/dist/CodeAuthorsCard.js +0 -1
  3. package/dist/CompletionPopup.js +150 -181
  4. package/dist/DiffView.d.ts +1 -1
  5. package/dist/DiffView.js +2 -29
  6. package/dist/PickPopup.js +54 -80
  7. package/dist/SplitDiffView.js +27 -57
  8. package/dist/SymbolPicker.js +2 -8
  9. package/dist/code-actions-model.js +2 -9
  10. package/dist/code-actions.js +10 -11
  11. package/dist/code-vision.js +2 -1
  12. package/dist/completion-model.js +2 -2
  13. package/dist/completion.js +13 -34
  14. package/dist/definition-link.d.ts +1 -0
  15. package/dist/definition-link.js +22 -18
  16. package/dist/diagnostics-model.d.ts +1 -0
  17. package/dist/diagnostics-model.js +3 -1
  18. package/dist/diagnostics.js +1 -2
  19. package/dist/diff-syntax.d.ts +1 -0
  20. package/dist/diff-syntax.js +31 -0
  21. package/dist/doc-comment-model.d.ts +1 -0
  22. package/dist/doc-comment-model.js +1 -1
  23. package/dist/folding.js +2 -3
  24. package/dist/format-on-type.js +6 -10
  25. package/dist/hover-content.d.ts +0 -2
  26. package/dist/hover-content.js +1 -9
  27. package/dist/inlay-hints.d.ts +0 -1
  28. package/dist/inlay-hints.js +11 -41
  29. package/dist/navigation.js +4 -14
  30. package/dist/peek.js +2 -12
  31. package/dist/popup-focus.d.ts +3 -1
  32. package/dist/popup-focus.js +33 -0
  33. package/dist/recent-choices.d.ts +0 -1
  34. package/dist/recent-choices.js +0 -4
  35. package/dist/rendered-docs.d.ts +1 -0
  36. package/dist/rendered-docs.js +15 -28
  37. package/dist/snippet-field-transforms.d.ts +5 -0
  38. package/dist/snippet-field-transforms.js +27 -16
  39. package/dist/snippet-session.js +8 -20
  40. package/dist/symbol-links.js +3 -10
  41. package/dist/symbol-picker-model.d.ts +1 -0
  42. package/dist/symbol-picker-model.js +7 -0
  43. package/dist/unique.d.ts +10 -0
  44. package/dist/unique.js +15 -0
  45. package/package.json +9 -9
  46. package/src/AnchoredPopup.tsx +2 -0
  47. package/src/BaselineStatus.tsx +1 -0
  48. package/src/CallHierarchyPanel.tsx +16 -6
  49. package/src/ChangeReview.tsx +7 -0
  50. package/src/CodeAuthorsCard.tsx +2 -5
  51. package/src/CompletionPopup.tsx +21 -15
  52. package/src/DiffView.tsx +53 -30
  53. package/src/EditorContextMenu.tsx +7 -0
  54. package/src/EditorView.tsx +28 -0
  55. package/src/FindReplace.tsx +8 -0
  56. package/src/HoverCard.tsx +8 -0
  57. package/src/HoverSections.tsx +11 -0
  58. package/src/LanguagePopups.tsx +1 -0
  59. package/src/PanelResizeHandle.tsx +8 -0
  60. package/src/PathText.tsx +1 -0
  61. package/src/PeekPanel.tsx +13 -0
  62. package/src/PickPopup.tsx +7 -6
  63. package/src/RenameCard.tsx +13 -0
  64. package/src/RenderedDocs.tsx +11 -0
  65. package/src/SplitDiffView.tsx +91 -56
  66. package/src/SymbolPicker.tsx +10 -18
  67. package/src/call-hierarchy-model.ts +10 -0
  68. package/src/call-hierarchy.ts +82 -0
  69. package/src/code-actions-model.ts +21 -9
  70. package/src/code-actions.ts +83 -7
  71. package/src/code-authors.ts +20 -0
  72. package/src/code-vision-model.ts +37 -0
  73. package/src/code-vision.ts +47 -3
  74. package/src/completion-call.ts +19 -0
  75. package/src/completion-model.ts +58 -2
  76. package/src/completion.ts +156 -30
  77. package/src/conflict-model.ts +4 -0
  78. package/src/context-menu-rows.ts +1 -0
  79. package/src/context-menu.ts +7 -0
  80. package/src/definition-link.ts +42 -18
  81. package/src/diagnostics-model.ts +19 -1
  82. package/src/diagnostics.ts +31 -2
  83. package/src/diff-expansion.ts +9 -0
  84. package/src/diff-overlays.tsx +8 -0
  85. package/src/diff-syntax.ts +54 -0
  86. package/src/diff-wordmarks.ts +8 -0
  87. package/src/doc-comment-model.ts +51 -1
  88. package/src/editor-language.ts +11 -0
  89. package/src/find-query.ts +7 -0
  90. package/src/folding.ts +10 -3
  91. package/src/format-on-type.ts +23 -10
  92. package/src/highlight-layers.ts +3 -0
  93. package/src/highlights.ts +18 -0
  94. package/src/history.ts +11 -0
  95. package/src/holder.ts +3 -0
  96. package/src/hover-content.ts +34 -17
  97. package/src/hover.ts +57 -0
  98. package/src/index.ts +4 -0
  99. package/src/inlay-hints.ts +86 -47
  100. package/src/key-priority.ts +2 -0
  101. package/src/line-edits.ts +8 -0
  102. package/src/models.ts +6 -0
  103. package/src/navigation-history.ts +5 -0
  104. package/src/navigation.ts +28 -13
  105. package/src/panel-size.ts +7 -0
  106. package/src/patch-decorations.ts +39 -0
  107. package/src/patch-layout.ts +47 -0
  108. package/src/peek-model.ts +21 -0
  109. package/src/peek.ts +49 -13
  110. package/src/pick.ts +35 -0
  111. package/src/popup-focus.ts +11 -1
  112. package/src/popup-placement.ts +5 -0
  113. package/src/popups.ts +2 -6
  114. package/src/project-language.ts +33 -0
  115. package/src/project-problems.ts +11 -0
  116. package/src/proposal-model.ts +40 -0
  117. package/src/recent-choices.ts +3 -5
  118. package/src/refresher.ts +3 -0
  119. package/src/rename-model.ts +28 -0
  120. package/src/rename.ts +56 -0
  121. package/src/rendered-docs.ts +89 -19
  122. package/src/review-group.ts +4 -0
  123. package/src/row-host.ts +12 -0
  124. package/src/selection-ranges.ts +6 -0
  125. package/src/semantic-delta.ts +7 -0
  126. package/src/semantic-model.ts +11 -0
  127. package/src/semantic-tokens.ts +37 -0
  128. package/src/shortcut-keys.ts +3 -0
  129. package/src/signature-model.ts +7 -0
  130. package/src/signature.ts +27 -0
  131. package/src/snippet-field-transforms.ts +48 -13
  132. package/src/snippet-indent.ts +10 -0
  133. package/src/snippet-range-order.ts +5 -0
  134. package/src/snippet-session.ts +173 -31
  135. package/src/snippet-transform-client.ts +14 -0
  136. package/src/snippet-transform-format.ts +98 -0
  137. package/src/snippet-transform-protocol.ts +5 -0
  138. package/src/snippet-transform-queue.ts +53 -0
  139. package/src/snippet-transform-worker.ts +14 -0
  140. package/src/snippet-variables.ts +9 -0
  141. package/src/snippet.ts +107 -0
  142. package/src/split-alignment.ts +10 -0
  143. package/src/split-scroll.ts +8 -0
  144. package/src/symbol-links.ts +9 -12
  145. package/src/symbol-picker-model.ts +25 -0
  146. package/src/symbol-picker.ts +9 -0
  147. package/src/symbols.ts +21 -0
  148. package/src/testing.ts +7 -0
  149. package/src/timers.ts +5 -0
  150. package/src/unique.ts +23 -0
  151. package/src/use-problem-counts.ts +4 -0
  152. package/src/view-state.ts +9 -0
  153. package/src/word-diff.ts +5 -0
  154. package/src/workspace-edit.ts +84 -0
@@ -1,3 +1,4 @@
1
+ import { uniqueBy } from './unique.js';
1
2
  /* The classes a name in a signature has: a pointer and an underline on hover, and the accent only then, so the signature stays calm. */
2
3
  export const SYMBOL_LINK_CLASS = 'cursor-pointer hover:text-accent hover:underline';
3
4
  const TYPE_NAME = /^[A-Z][A-Za-z0-9_]*$/;
@@ -34,8 +35,7 @@ export function placesOfName(result, name, fileText) {
34
35
  candidates = named;
35
36
  }
36
37
  }
37
- const seen = new Set();
38
- return candidates.map(symbol => 'range' in symbol.location ? symbol.location : {
38
+ return uniqueBy(candidates.map(symbol => 'range' in symbol.location ? symbol.location : {
39
39
  uri: symbol.location.uri,
40
40
  range: {
41
41
  start: {
@@ -47,12 +47,5 @@ export function placesOfName(result, name, fileText) {
47
47
  character: 0
48
48
  }
49
49
  }
50
- }).filter(location => {
51
- const key = `${location.uri}\0${location.range.start.line}`;
52
- if (seen.has(key)) {
53
- return false;
54
- }
55
- seen.add(key);
56
- return true;
57
- });
50
+ }), location => `${location.uri}\0${location.range.start.line}`);
58
51
  }
@@ -4,6 +4,7 @@ export declare const SYMBOL_GROUPS: readonly SymbolGroup[];
4
4
  export declare function groupOfKind(kind: number): SymbolGroup;
5
5
  export declare function letterOfKind(kind: number): string;
6
6
  export type SymbolTone = 'callable' | 'value' | 'type' | 'other';
7
+ export declare const TONE_CLASSES: Record<SymbolTone, string>;
7
8
  export declare function toneOfKind(kind: number): SymbolTone;
8
9
  export interface SymbolEntry {
9
10
  readonly id: string;
@@ -38,6 +38,13 @@ const LETTERS = {
38
38
  export function letterOfKind(kind) {
39
39
  return kind === ENUM_MEMBER ? 'e' : LETTERS[groupOfKind(kind)];
40
40
  }
41
+ /* The badge colors of a tone, shared by the symbol picker and the suggestions list. */
42
+ export const TONE_CLASSES = {
43
+ callable: 'bg-accent-soft text-accent',
44
+ value: 'bg-status-running/15 text-status-running',
45
+ type: 'bg-status-idle/15 text-status-idle',
46
+ other: 'bg-surface-hover text-text-muted'
47
+ };
41
48
  const TONES = {
42
49
  namespaces: 'other',
43
50
  classes: 'type',
@@ -0,0 +1,10 @@
1
+ export declare function uniqueBy<T>(items: readonly T[], keyOf: (item: T) => string): T[];
2
+ export declare function placeKey(location: {
3
+ readonly uri: string;
4
+ readonly range: {
5
+ readonly start: {
6
+ readonly line: number;
7
+ readonly character: number;
8
+ };
9
+ };
10
+ }): string;
package/dist/unique.js ADDED
@@ -0,0 +1,15 @@
1
+ /* The items in order, without any whose key an earlier one had. */
2
+ export function uniqueBy(items, keyOf) {
3
+ const seen = new Set();
4
+ return items.filter(item => {
5
+ const key = keyOf(item);
6
+ if (seen.has(key)) {
7
+ return false;
8
+ }
9
+ seen.add(key);
10
+ return true;
11
+ });
12
+ }
13
+ export function placeKey(location) {
14
+ return `${location.uri}\0${location.range.start.line}\0${location.range.start.character}`;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-react",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "description": "Language-enabled editors, feature coordination and React popups over injected language services.",
6
6
  "license": "FSL-1.1-MIT",
@@ -62,25 +62,25 @@
62
62
  "test": "bun test --conditions=source"
63
63
  },
64
64
  "dependencies": {
65
- "@adecore/editor": "0.39.0",
66
- "@adecore/lsp": "0.39.0",
67
- "@adecore/merge": "0.39.0",
68
- "lucide-react": "^1.47.0",
65
+ "@adecore/editor": "0.40.0",
66
+ "@adecore/lsp": "0.40.0",
67
+ "@adecore/merge": "0.40.0",
68
+ "lucide-react": "^1.61.0",
69
69
  "react-markdown": "^10.1.0",
70
70
  "remark-breaks": "^4.0.0",
71
71
  "remark-gfm": "^4.0.1",
72
- "zustand": "^5.0.15"
72
+ "zustand": "^5.0.16"
73
73
  },
74
74
  "peerDependencies": {
75
- "@adecore/ui": "^0.39.0",
75
+ "@adecore/ui": "^0.40.0",
76
76
  "i18next": "^26.4.2",
77
77
  "react": "^19.3.0",
78
78
  "react-dom": "^19.3.0",
79
79
  "react-i18next": "^17.0.15"
80
80
  },
81
81
  "devDependencies": {
82
- "@adecore/ui": "0.39.0",
83
- "@types/bun": "^1.4.2",
82
+ "@adecore/ui": "0.40.0",
83
+ "@types/bun": "^1.4.4",
84
84
  "@types/react": "^19.3.0",
85
85
  "@types/react-dom": "^19.3.0",
86
86
  "typescript": "^7.0.2"
@@ -25,9 +25,11 @@ export function AnchoredPopup({ rect, className, overflow = 'scroll', children,
25
25
  // Every render, since the character moves when the editor scrolls and the card's own size follows its content.
26
26
  useLayoutEffect(() => {
27
27
  const popup = element.current;
28
+
28
29
  if (popup === null) {
29
30
  return;
30
31
  }
32
+
31
33
  popup.style.maxHeight = '';
32
34
  const size = { width: popup.offsetWidth, height: popup.offsetHeight };
33
35
  const placed = placePopup(rect, size, { width: window.innerWidth, height: window.innerHeight }, placement);
@@ -11,6 +11,7 @@ const BASELINE_MARKS: Record<BaselineLevel, { icon: typeof CircleCheck; color: s
11
11
  /* Where a web feature stands across browsers, as one line under a CSS property. */
12
12
  export function BaselineStatus({ baseline }: { baseline: Baseline }) {
13
13
  const { icon, color } = BASELINE_MARKS[baseline.level];
14
+
14
15
  return (
15
16
  <div className="flex items-start gap-1.5 text-xs/[18px] text-text-muted select-text">
16
17
  <Icon icon={icon} size={14} className={`mt-px shrink-0 ${color}`} />
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useLayoutEffect, useRef, type ReactNode, type Ref } from 'react';
1
+ import { useEffect, useId, useRef, type ReactNode, type Ref } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
@@ -16,6 +16,7 @@ import { PathText } from './PathText.tsx';
16
16
  import { Preview } from './PeekPanel.tsx';
17
17
  import type { CallHierarchyView } from './popups.ts';
18
18
  import { SymbolBadge } from './SymbolPicker.tsx';
19
+ import { useInputPopup } from './popup-focus.ts';
19
20
 
20
21
  const INDENT = 14;
21
22
  const BASE_INDENT = 8;
@@ -68,6 +69,7 @@ function SymbolRow({ language, row, active, id, ref }: { language: EditorLanguag
68
69
  const { item } = row;
69
70
  const path = relativeTo(fileUriToPath(item.uri) ?? item.uri, language.project.folder);
70
71
  const tool = language.callHierarchy;
72
+
71
73
  return (
72
74
  <Line
73
75
  ref={ref}
@@ -111,29 +113,30 @@ function Tree({ language, view }: { language: EditorLanguage; view: CallHierarch
111
113
  const { t } = useTranslation('editor');
112
114
  const treeId = useId();
113
115
  const activeRow = useRef<HTMLDivElement>(null);
116
+
114
117
  const idOf = (row: CallRow): string => `${treeId}-${row.id}`;
118
+
115
119
  const activeId = view.rows.some((row) => row.id === view.active) ? `${treeId}-${view.active}` : undefined;
116
120
 
117
121
  useEffect(() => {
118
122
  activeRow.current?.scrollIntoView({ block: 'nearest' });
119
123
  }, [view.active]);
120
124
 
121
- // The focus stays in the text, so the input is what names the tree and its active row.
122
- useLayoutEffect(() => {
123
- language.editor.setInputPopup({ controls: treeId, activeDescendant: activeId });
124
- return () => language.editor.setInputPopup(null);
125
- }, [language, treeId, activeId]);
125
+ useInputPopup(language, treeId, activeId);
126
126
 
127
127
  return (
128
128
  <div id={treeId} role="tree" aria-label={t('language.callHierarchy.label')} className="w-1/2 shrink-0 overflow-y-auto border-r border-border py-1">
129
129
  {view.rows.map((row) => {
130
130
  const active = row.id === view.active;
131
131
  const ref = active ? activeRow : undefined;
132
+
132
133
  if (row.kind === 'node') {
133
134
  return <SymbolRow key={row.id} ref={ref} language={language} row={row} active={active} id={idOf(row)} />;
134
135
  }
136
+
135
137
  if (row.kind === 'more') {
136
138
  const next = Math.min(row.hidden, CALL_LEVEL_LIMIT);
139
+
137
140
  return (
138
141
  <Line key={row.id} ref={ref} row={row} id={idOf(row)} active={active} onClick={() => language.callHierarchy.showMore(row.parent)}>
139
142
  <span className="font-sans text-xs text-accent">
@@ -142,6 +145,7 @@ function Tree({ language, view }: { language: EditorLanguage; view: CallHierarch
142
145
  </Line>
143
146
  );
144
147
  }
148
+
145
149
  return (
146
150
  <Line key={row.id} row={row} id={idOf(row)} active={false} onClick={() => language.callHierarchy.retry(row.parent)}>
147
151
  <StatusText row={row} direction={view.direction} />
@@ -154,6 +158,7 @@ function Tree({ language, view }: { language: EditorLanguage; view: CallHierarch
154
158
 
155
159
  function StatusText({ row, direction }: { row: Extract<CallRow, { kind: 'status' }>; direction: CallDirection }) {
156
160
  const { t } = useTranslation('editor');
161
+
157
162
  if (row.status === 'loading') {
158
163
  return (
159
164
  <span className="flex items-center gap-2 font-sans text-xs text-text-muted">
@@ -162,11 +167,13 @@ function StatusText({ row, direction }: { row: Extract<CallRow, { kind: 'status'
162
167
  </span>
163
168
  );
164
169
  }
170
+
165
171
  if (row.status === 'failed') {
166
172
  return (
167
173
  <span className="min-w-0 truncate font-sans text-xs text-status-error">{t('language.callHierarchy.retry', { message: row.message ?? '' })}</span>
168
174
  );
169
175
  }
176
+
170
177
  return (
171
178
  <span className="font-sans text-xs text-text-muted">
172
179
  {t(direction === 'incoming' ? 'language.callHierarchy.noIncoming' : 'language.callHierarchy.noOutgoing')}
@@ -182,12 +189,15 @@ function StatusText({ row, direction }: { row: Extract<CallRow, { kind: 'status'
182
189
  export function CallHierarchyPanel({ language, view }: { language: EditorLanguage; view: CallHierarchyView }) {
183
190
  const { t } = useTranslation('editor');
184
191
  const body = usePanelBody(language);
192
+
185
193
  if (view.container === null) {
186
194
  return null;
187
195
  }
196
+
188
197
  const folder = language.project.folder;
189
198
  const path = view.preview === null ? null : fileUriToPath(view.preview.uri);
190
199
  const shown = path === null ? '' : folderOf(relativeTo(path, folder));
200
+
191
201
  return createPortal(
192
202
  <div
193
203
  className="flex flex-col bg-surface pt-[2px] font-sans text-xs text-text"
@@ -14,15 +14,20 @@ export interface ChangeReviewProps {
14
14
 
15
15
  export function ChangeReview({ editor, selected, proposal, startLine = 1, actions, className, label }: ChangeReviewProps) {
16
16
  const code = useRef<HTMLDivElement>(null);
17
+
17
18
  useLayoutEffect(() => {
18
19
  const element = code.current;
20
+
19
21
  if (element === null) {
20
22
  return;
21
23
  }
24
+
22
25
  element.replaceChildren();
26
+
23
27
  for (const segment of diffSegments(selected, proposal, startLine)) {
24
28
  const row = element.ownerDocument.createElement('div');
25
29
  element.append(row);
30
+
26
31
  editor.renderCode(row, segment.text, {
27
32
  firstLine: segment.firstLine,
28
33
  ...(segment.kind === 'same'
@@ -32,8 +37,10 @@ export function ChangeReview({ editor, selected, proposal, startLine = 1, action
32
37
  emphasis: emphasisOf(segment)
33
38
  });
34
39
  }
40
+
35
41
  return () => element.replaceChildren();
36
42
  }, [editor, selected, proposal, startLine]);
43
+
37
44
  return (
38
45
  <section className={className} aria-label={label}>
39
46
  <div ref={code} />
@@ -1,4 +1,3 @@
1
- import { useMemo } from 'react';
2
1
  import { useTranslation } from 'react-i18next';
3
2
  import { GitCommitHorizontal, User, Users } from 'lucide-react';
4
3
  import { Button, Icon, Popover, Separator, Tooltip, useNow } from '@adecore/ui';
@@ -34,10 +33,8 @@ export function LatestCommit({ commit }: { commit: GitBlameCommit }) {
34
33
  export function CodeAuthorsCard({ language, view }: { language: EditorLanguage; view: AuthorsView }) {
35
34
  const { t } = useTranslation('editor');
36
35
  const { authors, uncommittedLines, latest } = view.authorship;
37
- const anchor = useMemo(() => {
38
- const { left, top, right, bottom } = view.anchor;
39
- return { getBoundingClientRect: () => new DOMRect(left, top, right - left, bottom - top) };
40
- }, [view.anchor]);
36
+ const { left, top, right, bottom } = view.anchor;
37
+ const anchor = { getBoundingClientRect: () => new DOMRect(left, top, right - left, bottom - top) };
41
38
 
42
39
  return (
43
40
  <Popover.Root open onOpenChange={(open, details) => !open && language.codeVision.closeAuthors(details.reason === 'escape-key')}>
@@ -4,36 +4,34 @@ import { useTranslation } from 'react-i18next';
4
4
  import { formatShortcut } from '@adecore/ui';
5
5
  import type { EditorRect } from '@adecore/editor';
6
6
  import type { EditorLanguage } from './editor-language.ts';
7
- import { kindLetterOf, kindToneOf, rowWindow, type KindTone } from './completion-model.ts';
7
+ import { kindLetterOf, kindToneOf, rowWindow } from './completion-model.ts';
8
8
  import { SourceLine, SymbolSections } from './HoverSections.tsx';
9
9
  import { PathText } from './PathText.tsx';
10
- import { keepFocusOutOfList } from './popup-focus.ts';
10
+ import { keepFocusOutOfList, useInputPopup } from './popup-focus.ts';
11
11
  import { placeBeside, placePopup } from './popup-placement.ts';
12
12
  import type { CompletionView } from './popups.ts';
13
-
14
- const TONES: Record<KindTone, string> = {
15
- callable: 'bg-accent-soft text-accent',
16
- value: 'bg-status-running/15 text-status-running',
17
- type: 'bg-status-idle/15 text-status-idle',
18
- other: 'bg-surface-hover text-text-muted'
19
- };
13
+ import { TONE_CLASSES } from './symbol-picker-model.ts';
20
14
 
21
15
  /* The label with the characters that match what was typed set apart. */
22
16
  function MarkedLabel({ label, matches }: { label: string; matches: readonly number[] }) {
23
17
  if (matches.length === 0) {
24
18
  return <>{label}</>;
25
19
  }
20
+
26
21
  const marked = new Set(matches);
27
22
  const runs: { text: string; marked: boolean }[] = [];
23
+
28
24
  for (let at = 0; at < label.length; at++) {
29
25
  const isMarked = marked.has(at);
30
26
  const last = runs[runs.length - 1];
27
+
31
28
  if (last !== undefined && last.marked === isMarked) {
32
29
  last.text += label[at];
33
30
  } else {
34
31
  runs.push({ text: label[at]!, marked: isMarked });
35
32
  }
36
33
  }
34
+
37
35
  return (
38
36
  <>
39
37
  {runs.map((run, index) =>
@@ -52,10 +50,13 @@ function MarkedLabel({ label, matches }: { label: string; matches: readonly numb
52
50
  const CARD = 'fixed top-0 left-0 z-(--z-popup) overflow-hidden rounded-lg border border-border bg-surface-raised text-text shadow-(--float-shadow)';
53
51
  const ROW_HEIGHT = 24;
54
52
  const VISIBLE_ROWS = 8;
53
+
55
54
  /* Rows drawn past the visible ones on each side, so a wheel turn lands on rows that exist. */
56
55
  const OVERSCAN_ROWS = 6;
56
+
57
57
  /* The padding the listbox has around its rows. */
58
58
  const LIST_PADDING = 4;
59
+
59
60
  /* What a row takes besides its name and namespace: padding, the kind badge and the gaps. */
60
61
  const ROW_CHROME = 72;
61
62
 
@@ -77,6 +78,7 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
77
78
  // Only the rows near the visible ones are drawn: a list of 150 is mostly out of sight, and every keystroke makes it again.
78
79
  const [listTop, setListTop] = useState(0);
79
80
  const need = Math.max(0, ...view.rows.slice(0, VISIBLE_ROWS * 2).map((row) => row.label.length + 2 + row.description.length));
81
+
80
82
  if (need > widest) {
81
83
  setWidest(need);
82
84
  }
@@ -84,13 +86,16 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
84
86
  // Every render: the word moves when the editor scrolls, and the list and the documentation change size with what they hold.
85
87
  useLayoutEffect(() => {
86
88
  const card = list.current;
89
+
87
90
  if (card === null) {
88
91
  return;
89
92
  }
93
+
90
94
  const viewport = {
91
95
  width: window.innerWidth,
92
96
  height: window.innerHeight
93
97
  };
98
+
94
99
  const side = docs.current;
95
100
  // Every size is read before the first write, so the page is laid out once and not after each of them.
96
101
  const size = { width: card.offsetWidth, height: card.offsetHeight };
@@ -99,12 +104,14 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
99
104
  card.style.left = `${Math.round(placed.left)}px`;
100
105
  card.style.top = `${Math.round(placed.top)}px`;
101
106
  card.style.visibility = 'visible';
107
+
102
108
  if (side !== null && sideSize !== null) {
103
109
  const box = {
104
110
  left: Math.round(placed.left),
105
111
  top: Math.round(placed.top),
106
112
  ...size
107
113
  };
114
+
108
115
  const beside = placeBeside(box, sideSize, viewport);
109
116
  side.style.left = `${Math.round(beside.left)}px`;
110
117
  side.style.top = `${Math.round(beside.top)}px`;
@@ -119,14 +126,12 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
119
126
 
120
127
  const total = view.rows.length;
121
128
  const { first, last } = rowWindow(listTop, view.active, total, ROW_HEIGHT, VISIBLE_ROWS, OVERSCAN_ROWS, LIST_PADDING);
129
+
122
130
  const optionId = (index: number): string => `${listId}-option-${index}`;
131
+
123
132
  const activeId = view.active >= first && view.active < last ? optionId(view.active) : undefined;
124
133
 
125
- // The focus stays in the text, so the input is what names the list and its active option.
126
- useLayoutEffect(() => {
127
- language.editor.setInputPopup({ controls: listId, activeDescendant: activeId });
128
- return () => language.editor.setInputPopup(null);
129
- }, [language, listId, activeId]);
134
+ useInputPopup(language, listId, activeId);
130
135
 
131
136
  return createPortal(
132
137
  <>
@@ -155,6 +160,7 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
155
160
  <div aria-hidden style={{ height: first * ROW_HEIGHT }} />
156
161
  {view.rows.slice(first, last).map((row, offset) => {
157
162
  const index = first + offset;
163
+
158
164
  return (
159
165
  <button
160
166
  key={`${index}:${row.label}`}
@@ -171,7 +177,7 @@ export function CompletionPopup({ language, view, rect }: { language: EditorLang
171
177
  onClick={() => void language.completion.accept(false, index)}
172
178
  >
173
179
  <span
174
- className={`grid size-4 shrink-0 place-items-center rounded-sm font-mono text-xs leading-none font-semibold ${TONES[kindToneOf(row.kind)]}`}
180
+ className={`grid size-4 shrink-0 place-items-center rounded-sm font-mono text-xs leading-none font-semibold ${TONE_CLASSES[kindToneOf(row.kind)]}`}
175
181
  >
176
182
  {kindLetterOf(row.kind)}
177
183
  </span>