@grackle-ai/web-components 0.107.2

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 (279) hide show
  1. package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
  2. package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
  3. package/.rush/temp/operation/_phase_build/all.log +18 -0
  4. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
  5. package/.rush/temp/operation/_phase_build/state.json +3 -0
  6. package/.rush/temp/operation/_phase_test/all.log +121 -0
  7. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
  8. package/.rush/temp/operation/_phase_test/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +938 -0
  10. package/.storybook/main.ts +22 -0
  11. package/.storybook/preview.tsx +30 -0
  12. package/config/rig.json +4 -0
  13. package/config/rush-project.json +12 -0
  14. package/dist/index.css +1 -0
  15. package/dist/index.js +39221 -0
  16. package/eslint.config.cjs +5 -0
  17. package/package.json +83 -0
  18. package/rush-logs/web-components._phase_build.cache.log +4 -0
  19. package/rush-logs/web-components._phase_test.cache.log +4 -0
  20. package/src/components/chat/ChatInput.module.scss +81 -0
  21. package/src/components/chat/ChatInput.stories.tsx +91 -0
  22. package/src/components/chat/ChatInput.tsx +168 -0
  23. package/src/components/chat/index.ts +6 -0
  24. package/src/components/dag/DagView.module.scss +149 -0
  25. package/src/components/dag/DagView.stories.tsx +125 -0
  26. package/src/components/dag/DagView.tsx +109 -0
  27. package/src/components/dag/TaskNode.stories.tsx +133 -0
  28. package/src/components/dag/TaskNode.tsx +40 -0
  29. package/src/components/dag/useDagLayout.ts +139 -0
  30. package/src/components/display/Breadcrumbs.module.scss +71 -0
  31. package/src/components/display/Breadcrumbs.stories.tsx +80 -0
  32. package/src/components/display/Breadcrumbs.tsx +46 -0
  33. package/src/components/display/Button.module.scss +110 -0
  34. package/src/components/display/Button.stories.tsx +88 -0
  35. package/src/components/display/Button.tsx +40 -0
  36. package/src/components/display/ConfirmDialog.module.scss +67 -0
  37. package/src/components/display/ConfirmDialog.stories.tsx +81 -0
  38. package/src/components/display/ConfirmDialog.tsx +88 -0
  39. package/src/components/display/CopyButton.module.scss +41 -0
  40. package/src/components/display/CopyButton.stories.tsx +78 -0
  41. package/src/components/display/CopyButton.tsx +64 -0
  42. package/src/components/display/DemoBanner.module.scss +37 -0
  43. package/src/components/display/DemoBanner.stories.tsx +40 -0
  44. package/src/components/display/DemoBanner.tsx +23 -0
  45. package/src/components/display/EventHoverRow.module.scss +102 -0
  46. package/src/components/display/EventHoverRow.stories.tsx +99 -0
  47. package/src/components/display/EventHoverRow.tsx +154 -0
  48. package/src/components/display/EventRenderer.module.scss +272 -0
  49. package/src/components/display/EventRenderer.stories.tsx +186 -0
  50. package/src/components/display/EventRenderer.tsx +271 -0
  51. package/src/components/display/EventStream.module.scss +93 -0
  52. package/src/components/display/EventStream.stories.tsx +249 -0
  53. package/src/components/display/EventStream.tsx +369 -0
  54. package/src/components/display/FloatingActionBar.module.scss +107 -0
  55. package/src/components/display/FloatingActionBar.stories.tsx +122 -0
  56. package/src/components/display/FloatingActionBar.tsx +119 -0
  57. package/src/components/display/SessionAttemptSelector.module.scss +50 -0
  58. package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
  59. package/src/components/display/SessionAttemptSelector.tsx +49 -0
  60. package/src/components/display/SessionPicker.module.scss +200 -0
  61. package/src/components/display/SessionPicker.stories.tsx +169 -0
  62. package/src/components/display/SessionPicker.tsx +214 -0
  63. package/src/components/display/Skeleton.module.scss +58 -0
  64. package/src/components/display/Skeleton.stories.tsx +94 -0
  65. package/src/components/display/Skeleton.tsx +127 -0
  66. package/src/components/display/Spinner.module.scss +41 -0
  67. package/src/components/display/Spinner.stories.tsx +66 -0
  68. package/src/components/display/Spinner.tsx +32 -0
  69. package/src/components/display/SplashScreen.module.scss +20 -0
  70. package/src/components/display/SplashScreen.stories.tsx +26 -0
  71. package/src/components/display/SplashScreen.tsx +16 -0
  72. package/src/components/display/SplitButton.module.scss +166 -0
  73. package/src/components/display/SplitButton.stories.tsx +95 -0
  74. package/src/components/display/SplitButton.tsx +128 -0
  75. package/src/components/display/Tooltip.module.scss +84 -0
  76. package/src/components/display/Tooltip.stories.tsx +240 -0
  77. package/src/components/display/Tooltip.tsx +184 -0
  78. package/src/components/display/extractText.test.tsx +48 -0
  79. package/src/components/display/index.ts +20 -0
  80. package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
  81. package/src/components/editable/EditableCheckbox.tsx +39 -0
  82. package/src/components/editable/EditableField.module.scss +135 -0
  83. package/src/components/editable/EditableSelect.tsx +164 -0
  84. package/src/components/editable/EditableTextArea.stories.tsx +50 -0
  85. package/src/components/editable/EditableTextArea.tsx +148 -0
  86. package/src/components/editable/EditableTextField.stories.tsx +62 -0
  87. package/src/components/editable/EditableTextField.tsx +153 -0
  88. package/src/components/editable/EnvironmentSelect.module.scss +17 -0
  89. package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
  90. package/src/components/editable/EnvironmentSelect.tsx +87 -0
  91. package/src/components/editable/index.ts +13 -0
  92. package/src/components/editable/useEditableField.test.tsx +233 -0
  93. package/src/components/editable/useEditableField.ts +173 -0
  94. package/src/components/index.ts +20 -0
  95. package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
  96. package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
  97. package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
  98. package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
  99. package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
  100. package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
  101. package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
  102. package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
  103. package/src/components/knowledge/KnowledgeNav.tsx +138 -0
  104. package/src/components/knowledge/index.ts +3 -0
  105. package/src/components/layout/AppNav.module.scss +82 -0
  106. package/src/components/layout/AppNav.stories.tsx +115 -0
  107. package/src/components/layout/AppNav.tsx +133 -0
  108. package/src/components/layout/BottomStatusBar.module.scss +58 -0
  109. package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
  110. package/src/components/layout/BottomStatusBar.tsx +206 -0
  111. package/src/components/layout/Sidebar.module.scss +60 -0
  112. package/src/components/layout/Sidebar.stories.tsx +46 -0
  113. package/src/components/layout/Sidebar.tsx +84 -0
  114. package/src/components/layout/StatusBar.module.scss +108 -0
  115. package/src/components/layout/StatusBar.stories.tsx +119 -0
  116. package/src/components/layout/StatusBar.tsx +70 -0
  117. package/src/components/layout/index.ts +9 -0
  118. package/src/components/lists/EnvironmentNav.module.scss +118 -0
  119. package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
  120. package/src/components/lists/EnvironmentNav.tsx +133 -0
  121. package/src/components/lists/FindingsNav.module.scss +126 -0
  122. package/src/components/lists/FindingsNav.tsx +146 -0
  123. package/src/components/lists/TaskList.module.scss +206 -0
  124. package/src/components/lists/TaskList.stories.tsx +401 -0
  125. package/src/components/lists/TaskList.tsx +509 -0
  126. package/src/components/lists/index.ts +6 -0
  127. package/src/components/lists/listHelpers.tsx +130 -0
  128. package/src/components/notifications/Callout.module.scss +83 -0
  129. package/src/components/notifications/Callout.stories.tsx +81 -0
  130. package/src/components/notifications/Callout.tsx +64 -0
  131. package/src/components/notifications/Toast.module.scss +86 -0
  132. package/src/components/notifications/Toast.stories.tsx +71 -0
  133. package/src/components/notifications/Toast.tsx +51 -0
  134. package/src/components/notifications/ToastContainer.module.scss +23 -0
  135. package/src/components/notifications/ToastContainer.stories.tsx +66 -0
  136. package/src/components/notifications/ToastContainer.tsx +29 -0
  137. package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
  138. package/src/components/notifications/UpdateBanner.test.tsx +64 -0
  139. package/src/components/notifications/UpdateBanner.tsx +44 -0
  140. package/src/components/notifications/index.ts +8 -0
  141. package/src/components/panels/AboutPanel.stories.tsx +70 -0
  142. package/src/components/panels/AboutPanel.tsx +66 -0
  143. package/src/components/panels/AppearancePanel.stories.tsx +45 -0
  144. package/src/components/panels/AppearancePanel.tsx +97 -0
  145. package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
  146. package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
  147. package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
  148. package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
  149. package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
  150. package/src/components/panels/FindingsPanel.module.scss +94 -0
  151. package/src/components/panels/FindingsPanel.stories.tsx +109 -0
  152. package/src/components/panels/FindingsPanel.tsx +76 -0
  153. package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
  154. package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
  155. package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
  156. package/src/components/panels/PluginsPanel.tsx +77 -0
  157. package/src/components/panels/SettingsPanel.module.scss +336 -0
  158. package/src/components/panels/TaskActionButtons.module.scss +22 -0
  159. package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
  160. package/src/components/panels/TaskActionButtons.tsx +87 -0
  161. package/src/components/panels/TaskEditPanel.module.scss +202 -0
  162. package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
  163. package/src/components/panels/TaskEditPanel.tsx +328 -0
  164. package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
  165. package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
  166. package/src/components/panels/TaskOverviewPanel.tsx +270 -0
  167. package/src/components/panels/TokensPanel.stories.tsx +131 -0
  168. package/src/components/panels/TokensPanel.tsx +143 -0
  169. package/src/components/panels/WorkpadPanel.module.scss +39 -0
  170. package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
  171. package/src/components/panels/WorkpadPanel.tsx +63 -0
  172. package/src/components/panels/index.ts +13 -0
  173. package/src/components/personas/McpToolSelector.module.scss +109 -0
  174. package/src/components/personas/McpToolSelector.stories.tsx +129 -0
  175. package/src/components/personas/McpToolSelector.tsx +180 -0
  176. package/src/components/personas/PersonaManager.module.scss +233 -0
  177. package/src/components/personas/PersonaManager.stories.tsx +139 -0
  178. package/src/components/personas/PersonaManager.tsx +122 -0
  179. package/src/components/schedules/ScheduleManager.module.scss +98 -0
  180. package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
  181. package/src/components/schedules/ScheduleManager.tsx +160 -0
  182. package/src/components/settings/SettingsNav.module.scss +82 -0
  183. package/src/components/settings/SettingsNav.stories.tsx +83 -0
  184. package/src/components/settings/SettingsNav.tsx +104 -0
  185. package/src/components/streams/StreamDetailPanel.module.scss +206 -0
  186. package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
  187. package/src/components/streams/StreamDetailPanel.tsx +119 -0
  188. package/src/components/streams/StreamList.module.scss +92 -0
  189. package/src/components/streams/StreamList.stories.tsx +99 -0
  190. package/src/components/streams/StreamList.tsx +114 -0
  191. package/src/components/streams/index.ts +10 -0
  192. package/src/components/tools/AgentToolCard.module.scss +118 -0
  193. package/src/components/tools/AgentToolCard.stories.tsx +304 -0
  194. package/src/components/tools/AgentToolCard.tsx +247 -0
  195. package/src/components/tools/FileEditCard.stories.tsx +138 -0
  196. package/src/components/tools/FileEditCard.tsx +160 -0
  197. package/src/components/tools/FileReadCard.stories.tsx +120 -0
  198. package/src/components/tools/FileReadCard.tsx +106 -0
  199. package/src/components/tools/FindingCard.stories.tsx +124 -0
  200. package/src/components/tools/FindingCard.tsx +178 -0
  201. package/src/components/tools/GenericToolCard.stories.tsx +80 -0
  202. package/src/components/tools/GenericToolCard.tsx +111 -0
  203. package/src/components/tools/IpcCard.stories.tsx +129 -0
  204. package/src/components/tools/IpcCard.tsx +178 -0
  205. package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
  206. package/src/components/tools/KnowledgeCard.tsx +165 -0
  207. package/src/components/tools/MetadataCard.stories.tsx +32 -0
  208. package/src/components/tools/MetadataCard.tsx +39 -0
  209. package/src/components/tools/SearchCard.stories.tsx +74 -0
  210. package/src/components/tools/SearchCard.tsx +86 -0
  211. package/src/components/tools/ShellCard.stories.tsx +112 -0
  212. package/src/components/tools/ShellCard.tsx +106 -0
  213. package/src/components/tools/TaskCard.stories.tsx +123 -0
  214. package/src/components/tools/TaskCard.tsx +203 -0
  215. package/src/components/tools/TodoCard.module.scss +131 -0
  216. package/src/components/tools/TodoCard.stories.tsx +202 -0
  217. package/src/components/tools/TodoCard.tsx +200 -0
  218. package/src/components/tools/ToolCard.stories.tsx +177 -0
  219. package/src/components/tools/ToolCard.tsx +60 -0
  220. package/src/components/tools/ToolCardProps.ts +20 -0
  221. package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
  222. package/src/components/tools/ToolSearchCard.tsx +86 -0
  223. package/src/components/tools/WorkpadCard.stories.tsx +106 -0
  224. package/src/components/tools/WorkpadCard.tsx +125 -0
  225. package/src/components/tools/classifyTool.test.ts +44 -0
  226. package/src/components/tools/classifyTool.ts +134 -0
  227. package/src/components/tools/parseDiff.ts +95 -0
  228. package/src/components/tools/parseShellOutput.ts +28 -0
  229. package/src/components/tools/toolCardHelpers.test.ts +53 -0
  230. package/src/components/tools/toolCards.module.scss +234 -0
  231. package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
  232. package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
  233. package/src/components/workspace/WorkspaceBoard.tsx +232 -0
  234. package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
  235. package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
  236. package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
  237. package/src/context/GrackleContext.ts +28 -0
  238. package/src/context/GrackleContextTypes.ts +64 -0
  239. package/src/context/SidebarContext.tsx +53 -0
  240. package/src/context/ThemeContext.tsx +21 -0
  241. package/src/context/ToastContext.tsx +56 -0
  242. package/src/hooks/types.ts +864 -0
  243. package/src/hooks/useEventSelection.test.ts +204 -0
  244. package/src/hooks/useEventSelection.ts +158 -0
  245. package/src/hooks/useSmartScroll.ts +151 -0
  246. package/src/hooks/useTheme.ts +228 -0
  247. package/src/index.ts +210 -0
  248. package/src/mocks/MockGrackleProvider.tsx +1397 -0
  249. package/src/mocks/mockData.ts +1966 -0
  250. package/src/mocks/mockKnowledgeData.ts +294 -0
  251. package/src/scss.d.ts +12 -0
  252. package/src/styles/global.scss +244 -0
  253. package/src/styles/mixins.scss +278 -0
  254. package/src/styles/prism-theme.scss +148 -0
  255. package/src/styles/theme.scss +1102 -0
  256. package/src/test-utils/storybook-decorators.tsx +50 -0
  257. package/src/test-utils/storybook-helpers.ts +262 -0
  258. package/src/themes.ts +142 -0
  259. package/src/utils/boardColumns.ts +141 -0
  260. package/src/utils/breadcrumbs.test.ts +285 -0
  261. package/src/utils/breadcrumbs.ts +222 -0
  262. package/src/utils/dashboard.test.ts +156 -0
  263. package/src/utils/dashboard.ts +195 -0
  264. package/src/utils/eventContent.test.ts +353 -0
  265. package/src/utils/eventContent.ts +209 -0
  266. package/src/utils/findingCategory.ts +33 -0
  267. package/src/utils/format.ts +27 -0
  268. package/src/utils/iconSize.ts +18 -0
  269. package/src/utils/navigation.ts +205 -0
  270. package/src/utils/route-config.test.ts +128 -0
  271. package/src/utils/scrollUtils.test.ts +65 -0
  272. package/src/utils/scrollUtils.ts +49 -0
  273. package/src/utils/sessionEvents.test.ts +302 -0
  274. package/src/utils/sessionEvents.ts +233 -0
  275. package/src/utils/taskStatus.tsx +137 -0
  276. package/src/utils/time.ts +92 -0
  277. package/tsconfig.json +8 -0
  278. package/vite.config.ts +20 -0
  279. package/vitest.config.ts +10 -0
@@ -0,0 +1,153 @@
1
+ import { useEffect, useRef, type JSX, type ReactNode } from "react";
2
+ import { useEditableField } from "./useEditableField.js";
3
+ import styles from "./EditableField.module.scss";
4
+
5
+ /** Props for EditableTextField. */
6
+ export interface EditableTextFieldProps {
7
+ /** Current persisted value. */
8
+ value: string;
9
+ /** Called when the user saves. Required in edit mode. */
10
+ onSave: (value: string) => void;
11
+ /** Optional validation — return an error string, or undefined if valid. */
12
+ validate?: (value: string) => string | undefined;
13
+ /** "edit" (default) for click-to-edit, "create" for always-editable. */
14
+ mode?: "edit" | "create";
15
+ /** Unique field identifier for coordination. */
16
+ fieldId?: string;
17
+ /** Which field is currently being edited (parent coordination). */
18
+ activeFieldId?: string | null; // eslint-disable-line @rushstack/no-new-null
19
+ /** Callback to tell the parent which field is active. */
20
+ onActivate?: (fieldId: string | null) => void; // eslint-disable-line @rushstack/no-new-null
21
+ /** Called on every keystroke in create mode. */
22
+ onChange?: (value: string) => void;
23
+ /** Custom display renderer (e.g., link for repoUrl). */
24
+ renderDisplay?: (value: string) => ReactNode | undefined;
25
+ /** Placeholder text shown when empty. */
26
+ placeholder?: string;
27
+ /** Max character length for the input. */
28
+ maxLength?: number;
29
+ /** Accessible label for the input. */
30
+ ariaLabel?: string;
31
+ /** Base test ID — gets `-input` / `-button` suffixes appended. */
32
+ "data-testid"?: string;
33
+ }
34
+
35
+ /** Reusable click-to-edit text input field. */
36
+ export function EditableTextField(props: EditableTextFieldProps): JSX.Element {
37
+ const {
38
+ value,
39
+ onSave,
40
+ validate,
41
+ mode = "edit",
42
+ fieldId = "text",
43
+ activeFieldId,
44
+ onActivate,
45
+ onChange,
46
+ renderDisplay,
47
+ placeholder,
48
+ maxLength,
49
+ ariaLabel,
50
+ "data-testid": testId,
51
+ } = props;
52
+
53
+ const inputRef = useRef<HTMLInputElement>(null);
54
+
55
+ const field = useEditableField({
56
+ value,
57
+ onSave,
58
+ validate,
59
+ fieldId,
60
+ activeFieldId,
61
+ onActivate,
62
+ enterToSave: true,
63
+ trimOnSave: true,
64
+ });
65
+
66
+ // Auto-focus when entering edit mode
67
+ useEffect(() => {
68
+ if (field.isEditing) {
69
+ const timer = window.setTimeout(() => {
70
+ inputRef.current?.focus();
71
+ }, 0);
72
+ return () => window.clearTimeout(timer);
73
+ }
74
+ }, [field.isEditing]);
75
+
76
+ // Create mode: always show input, no blur-to-save
77
+ if (mode === "create") {
78
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
79
+ onChange?.(e.target.value);
80
+ };
81
+
82
+ const validationError = validate?.(value);
83
+
84
+ return (
85
+ <div className={styles.editFieldWrapper}>
86
+ <input
87
+ className={`${styles.editInput} ${validationError ? styles.editInputInvalid : ""}`}
88
+ value={value}
89
+ onChange={handleChange}
90
+ maxLength={maxLength}
91
+ placeholder={placeholder}
92
+ aria-label={ariaLabel}
93
+ data-testid={testId ? `${testId}-input` : undefined}
94
+ />
95
+ {validationError && (
96
+ <span className={styles.editError} data-testid="edit-error">{validationError}</span>
97
+ )}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ // Edit mode: toggle between display and input
103
+ if (field.isEditing) {
104
+ return (
105
+ <div className={styles.editFieldWrapper}>
106
+ <input
107
+ ref={inputRef}
108
+ className={`${styles.editInput} ${field.error ? styles.editInputInvalid : ""}`}
109
+ value={field.draft}
110
+ onChange={(e) => field.setDraft(e.target.value)}
111
+ onBlur={field.handleBlur}
112
+ onKeyDown={field.handleKeyDown}
113
+ maxLength={maxLength}
114
+ placeholder={placeholder}
115
+ aria-label={ariaLabel}
116
+ data-testid={testId ? `${testId}-input` : undefined}
117
+ />
118
+ {field.isDirty && <span className={styles.unsavedDot} title="Unsaved changes" />}
119
+ {field.error && (
120
+ <span className={styles.editError} data-testid="edit-error">{field.error}</span>
121
+ )}
122
+ <span className={styles.editHint}>Enter to save &middot; Esc to cancel</span>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ // Display mode — uses <span role="button"> to avoid nested interactive elements
128
+ // when renderDisplay returns links or other interactive content
129
+ const displayContent = renderDisplay?.(value);
130
+ return (
131
+ <span
132
+ role="button"
133
+ tabIndex={0}
134
+ className={styles.metaValueClickable}
135
+ onClick={() => field.startEdit()}
136
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); field.startEdit(); } }}
137
+ title="Click to edit"
138
+ aria-label={ariaLabel}
139
+ data-testid={testId ? `${testId}-button` : undefined}
140
+ >
141
+ {displayContent !== undefined ? displayContent : (
142
+ value ? (
143
+ <span>{value}</span>
144
+ ) : (
145
+ <span className={styles.metaPlaceholder}>{placeholder || "None"}</span>
146
+ )
147
+ )}
148
+ <span className={styles.editButton} aria-hidden="true">
149
+ &#x270F;&#xFE0F;
150
+ </span>
151
+ </span>
152
+ );
153
+ }
@@ -0,0 +1,17 @@
1
+ .envRow {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--space-sm);
5
+ }
6
+
7
+ .envDot {
8
+ width: 8px;
9
+ height: 8px;
10
+ border-radius: 50%;
11
+ flex-shrink: 0;
12
+ }
13
+
14
+ .envDotGreen { background: var(--accent-green); }
15
+ .envDotYellow { background: var(--accent-yellow); }
16
+ .envDotRed { background: var(--accent-red); }
17
+ .envDotGray { background: var(--text-disabled); }
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn } from "@storybook/test";
3
+ import { EnvironmentSelect } from "./EnvironmentSelect.js";
4
+ import type { Environment } from "../../hooks/types.js";
5
+ import { makeEnvironment } from "../../test-utils/storybook-helpers.js";
6
+
7
+ const localEnv: Environment = makeEnvironment({
8
+ id: "env-local",
9
+ displayName: "Local Machine",
10
+ adapterType: "local",
11
+ status: "connected",
12
+ });
13
+
14
+ const sshEnv: Environment = makeEnvironment({
15
+ id: "env-ssh",
16
+ displayName: "Dev Server (SSH)",
17
+ adapterType: "ssh",
18
+ status: "ready",
19
+ });
20
+
21
+ const failedEnv: Environment = makeEnvironment({
22
+ id: "env-fail",
23
+ displayName: "Broken Host",
24
+ adapterType: "ssh",
25
+ status: "error",
26
+ });
27
+
28
+ const meta: Meta<typeof EnvironmentSelect> = {
29
+ component: EnvironmentSelect,
30
+ title: "App/Editable/EnvironmentSelect",
31
+ args: {
32
+ onSave: fn(),
33
+ environments: [localEnv, sshEnv],
34
+ value: localEnv.id,
35
+ "data-testid": "env-select",
36
+ },
37
+ };
38
+ export default meta;
39
+ type Story = StoryObj<typeof meta>;
40
+
41
+ /** Default state showing the selected environment with status dot. */
42
+ export const Default: Story = {
43
+ play: async ({ canvas }) => {
44
+ const button = canvas.getByTestId("env-select-button");
45
+ await expect(button).toBeInTheDocument();
46
+ await expect(button).toHaveTextContent("Local Machine");
47
+ },
48
+ };
49
+
50
+ /** Multiple environments including various statuses. */
51
+ export const WithMultipleEnvironments: Story = {
52
+ args: {
53
+ environments: [localEnv, sshEnv, failedEnv],
54
+ value: sshEnv.id,
55
+ },
56
+ play: async ({ canvas }) => {
57
+ const button = canvas.getByTestId("env-select-button");
58
+ await expect(button).toBeInTheDocument();
59
+ await expect(button).toHaveTextContent("Dev Server (SSH)");
60
+ },
61
+ };
@@ -0,0 +1,87 @@
1
+ import { type JSX, type ReactNode } from "react";
2
+ import { EditableSelect } from "./EditableSelect.js";
3
+ import type { Environment } from "../../hooks/types.js";
4
+ import styles from "./EnvironmentSelect.module.scss";
5
+
6
+ /** Props for EnvironmentSelect. */
7
+ export interface EnvironmentSelectProps {
8
+ /** Currently selected environment ID. */
9
+ value: string;
10
+ /** Called when the user selects a new environment. */
11
+ onSave: (envId: string) => void;
12
+ /** Available environments. */
13
+ environments: Environment[];
14
+ /** Whether to include a "None" option. */
15
+ allowNone?: boolean;
16
+ /** Unique field identifier for coordination with other editable fields. */
17
+ fieldId?: string;
18
+ /** Which field is currently being edited (parent coordination). */
19
+ activeFieldId?: string | null; // eslint-disable-line @rushstack/no-new-null
20
+ /** Callback to tell the parent which field is active. */
21
+ onActivate?: (fieldId: string | null) => void; // eslint-disable-line @rushstack/no-new-null
22
+ /** Placeholder text when no value is selected. */
23
+ placeholder?: string;
24
+ /** Accessible label. */
25
+ ariaLabel?: string;
26
+ /** Base test ID. */
27
+ "data-testid"?: string;
28
+ }
29
+
30
+ /** Map environment status to a CSS class for the status dot. */
31
+ function envStatusClass(status: string): string {
32
+ const s = status.toLowerCase();
33
+ if (s === "ready" || s === "running" || s === "available" || s === "connected") return styles.envDotGreen;
34
+ if (s === "provisioning" || s === "starting" || s === "pending" || s === "connecting") return styles.envDotYellow;
35
+ if (s === "error" || s === "failed" || s === "disconnected") return styles.envDotRed;
36
+ return styles.envDotGray;
37
+ }
38
+
39
+ /** Reusable environment selector with status dot display. Click-to-edit EditableSelect. */
40
+ export function EnvironmentSelect(props: EnvironmentSelectProps): JSX.Element {
41
+ const {
42
+ value,
43
+ onSave,
44
+ environments,
45
+ allowNone = false,
46
+ fieldId = "environment",
47
+ activeFieldId,
48
+ onActivate,
49
+ placeholder = "No environment",
50
+ ariaLabel = "Environment",
51
+ "data-testid": testId,
52
+ } = props;
53
+
54
+ const selectedEnv = environments.find((e) => e.id === value);
55
+
56
+ const options = [
57
+ ...(allowNone ? [{ value: "", label: "None" }] : []),
58
+ ...environments.map((env) => ({ value: env.id, label: env.displayName })),
59
+ ];
60
+
61
+ const renderDisplay = (): ReactNode | undefined => {
62
+ if (selectedEnv) {
63
+ return (
64
+ <span className={styles.envRow}>
65
+ <span className={`${styles.envDot} ${envStatusClass(selectedEnv.status)}`} />
66
+ {selectedEnv.displayName}
67
+ </span>
68
+ );
69
+ }
70
+ return undefined;
71
+ };
72
+
73
+ return (
74
+ <EditableSelect
75
+ value={value}
76
+ onSave={onSave}
77
+ options={options}
78
+ fieldId={fieldId}
79
+ activeFieldId={activeFieldId}
80
+ onActivate={onActivate}
81
+ renderDisplay={renderDisplay}
82
+ placeholder={placeholder}
83
+ ariaLabel={ariaLabel}
84
+ data-testid={testId}
85
+ />
86
+ );
87
+ }
@@ -0,0 +1,13 @@
1
+ export { EditableTextField } from "./EditableTextField.js";
2
+ export { EditableTextArea } from "./EditableTextArea.js";
3
+ export { EditableSelect } from "./EditableSelect.js";
4
+ export { EditableCheckbox } from "./EditableCheckbox.js";
5
+ export { EnvironmentSelect } from "./EnvironmentSelect.js";
6
+ export { useEditableField } from "./useEditableField.js";
7
+
8
+ export type { EditableTextFieldProps } from "./EditableTextField.js";
9
+ export type { EditableTextAreaProps } from "./EditableTextArea.js";
10
+ export type { EditableSelectProps, SelectOption } from "./EditableSelect.js";
11
+ export type { EditableCheckboxProps } from "./EditableCheckbox.js";
12
+ export type { EnvironmentSelectProps } from "./EnvironmentSelect.js";
13
+ export type { UseEditableFieldOptions, UseEditableFieldReturn } from "./useEditableField.js";
@@ -0,0 +1,233 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { renderHook, act } from "@testing-library/react";
4
+ import { useEditableField } from "./useEditableField.js";
5
+
6
+ function makeOptions(overrides: Partial<Parameters<typeof useEditableField>[0]> = {}): Parameters<typeof useEditableField>[0] {
7
+ return {
8
+ value: "hello",
9
+ onSave: vi.fn(),
10
+ fieldId: "name",
11
+ activeFieldId: null as string | null,
12
+ onActivate: vi.fn(),
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe("useEditableField", () => {
18
+ // ── Lifecycle: start / cancel / save ──────────────────────────
19
+ it("starts in non-editing state", () => {
20
+ const opts = makeOptions();
21
+ const { result } = renderHook(() => useEditableField(opts));
22
+ expect(result.current.isEditing).toBe(false);
23
+ expect(result.current.draft).toBe("");
24
+ expect(result.current.error).toBe("");
25
+ expect(result.current.isDirty).toBe(false);
26
+ });
27
+
28
+ it("startEdit activates the field and seeds the draft", () => {
29
+ const opts = makeOptions();
30
+ const { result } = renderHook(() => useEditableField(opts));
31
+
32
+ act(() => result.current.startEdit());
33
+ expect(opts.onActivate).toHaveBeenCalledWith("name");
34
+ });
35
+
36
+ it("cancelEdit deactivates and clears state", () => {
37
+ const opts = makeOptions({ activeFieldId: "name" });
38
+ const { result } = renderHook(() => useEditableField(opts));
39
+
40
+ // Seed draft
41
+ act(() => result.current.startEdit());
42
+ act(() => result.current.cancelEdit());
43
+
44
+ expect(opts.onActivate).toHaveBeenLastCalledWith(null);
45
+ });
46
+
47
+ it("save calls onSave with trimmed value and exits", () => {
48
+ const onSave = vi.fn();
49
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
50
+ const { result } = renderHook(() => useEditableField(opts));
51
+
52
+ act(() => result.current.startEdit());
53
+ act(() => result.current.setDraft(" new "));
54
+ act(() => result.current.save());
55
+
56
+ expect(onSave).toHaveBeenCalledWith("new");
57
+ expect(opts.onActivate).toHaveBeenLastCalledWith(null);
58
+ });
59
+
60
+ it("save without trimOnSave preserves whitespace", () => {
61
+ const onSave = vi.fn();
62
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, trimOnSave: false });
63
+ const { result } = renderHook(() => useEditableField(opts));
64
+
65
+ act(() => result.current.startEdit());
66
+ act(() => result.current.setDraft(" new "));
67
+ act(() => result.current.save());
68
+
69
+ expect(onSave).toHaveBeenCalledWith(" new ");
70
+ });
71
+
72
+ it("no-op save when value is unchanged", () => {
73
+ const onSave = vi.fn();
74
+ const opts = makeOptions({ value: "hello", activeFieldId: "name", onSave });
75
+ const { result } = renderHook(() => useEditableField(opts));
76
+
77
+ act(() => result.current.startEdit());
78
+ // Draft is seeded with "hello" by startEdit, don't change it
79
+ act(() => result.current.save());
80
+
81
+ expect(onSave).not.toHaveBeenCalled();
82
+ expect(opts.onActivate).toHaveBeenLastCalledWith(null); // Still exits edit mode
83
+ });
84
+
85
+ // ── Validation ────────────────────────────────────────────────
86
+ it("save with validation error shows error and does not call onSave", () => {
87
+ const onSave = vi.fn();
88
+ const validate = vi.fn().mockReturnValue("Required");
89
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, validate });
90
+ const { result } = renderHook(() => useEditableField(opts));
91
+
92
+ act(() => result.current.startEdit());
93
+ act(() => result.current.setDraft(""));
94
+ act(() => result.current.save());
95
+
96
+ expect(result.current.error).toBe("Required");
97
+ expect(onSave).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("setDraft clears the error", () => {
101
+ const validate = vi.fn().mockReturnValue("Required");
102
+ const opts = makeOptions({ value: "old", activeFieldId: "name", validate });
103
+ const { result } = renderHook(() => useEditableField(opts));
104
+
105
+ act(() => result.current.startEdit());
106
+ act(() => result.current.setDraft(""));
107
+ act(() => result.current.save());
108
+ expect(result.current.error).toBe("Required");
109
+
110
+ act(() => result.current.setDraft("fixed"));
111
+ expect(result.current.error).toBe("");
112
+ });
113
+
114
+ // ── isDirty ───────────────────────────────────────────────────
115
+ it("isDirty is true when draft differs from value", () => {
116
+ const opts = makeOptions({ value: "hello", activeFieldId: "name" });
117
+ const { result } = renderHook(() => useEditableField(opts));
118
+
119
+ act(() => result.current.startEdit());
120
+ act(() => result.current.setDraft("changed"));
121
+ expect(result.current.isDirty).toBe(true);
122
+ });
123
+
124
+ it("isDirty is false when draft matches value (after trim)", () => {
125
+ const opts = makeOptions({ value: "hello", activeFieldId: "name" });
126
+ const { result } = renderHook(() => useEditableField(opts));
127
+
128
+ act(() => result.current.startEdit());
129
+ act(() => result.current.setDraft(" hello "));
130
+ expect(result.current.isDirty).toBe(false);
131
+ });
132
+
133
+ it("isDirty is false when not editing", () => {
134
+ const opts = makeOptions({ value: "hello", activeFieldId: null });
135
+ const { result } = renderHook(() => useEditableField(opts));
136
+ expect(result.current.isDirty).toBe(false);
137
+ });
138
+
139
+ // ── Keyboard handling ─────────────────────────────────────────
140
+ it("Escape cancels edit", () => {
141
+ const opts = makeOptions({ activeFieldId: "name" });
142
+ const { result } = renderHook(() => useEditableField(opts));
143
+
144
+ act(() => result.current.startEdit());
145
+ act(() => {
146
+ result.current.handleKeyDown({ key: "Escape" } as React.KeyboardEvent);
147
+ });
148
+
149
+ expect(opts.onActivate).toHaveBeenLastCalledWith(null);
150
+ });
151
+
152
+ it("Enter saves when enterToSave is true", () => {
153
+ const onSave = vi.fn();
154
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, enterToSave: true });
155
+ const { result } = renderHook(() => useEditableField(opts));
156
+
157
+ act(() => result.current.startEdit());
158
+ act(() => result.current.setDraft("new"));
159
+ act(() => {
160
+ result.current.handleKeyDown({ key: "Enter" } as React.KeyboardEvent);
161
+ });
162
+
163
+ expect(onSave).toHaveBeenCalledWith("new");
164
+ });
165
+
166
+ it("Enter does NOT save when enterToSave is false", () => {
167
+ const onSave = vi.fn();
168
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, enterToSave: false });
169
+ const { result } = renderHook(() => useEditableField(opts));
170
+
171
+ act(() => result.current.startEdit());
172
+ act(() => result.current.setDraft("new"));
173
+ act(() => {
174
+ result.current.handleKeyDown({ key: "Enter" } as React.KeyboardEvent);
175
+ });
176
+
177
+ expect(onSave).not.toHaveBeenCalled();
178
+ });
179
+
180
+ // ── Blur guard ────────────────────────────────────────────────
181
+ it("ignoreInitialBlurRef prevents first blur from saving", () => {
182
+ const onSave = vi.fn();
183
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
184
+ const { result } = renderHook(() => useEditableField(opts));
185
+
186
+ act(() => result.current.startEdit());
187
+ expect(result.current.ignoreInitialBlurRef.current).toBe(true);
188
+
189
+ // Simulate first blur — should be ignored
190
+ act(() => {
191
+ result.current.handleBlur({ relatedTarget: null } as unknown as React.FocusEvent);
192
+ });
193
+
194
+ expect(onSave).not.toHaveBeenCalled();
195
+ expect(result.current.ignoreInitialBlurRef.current).toBe(false);
196
+ });
197
+
198
+ it("blur with data-edit-action matching fieldId is ignored", () => {
199
+ const onSave = vi.fn();
200
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
201
+ const { result } = renderHook(() => useEditableField(opts));
202
+
203
+ act(() => result.current.startEdit());
204
+ // Clear the initial blur guard
205
+ result.current.ignoreInitialBlurRef.current = false;
206
+
207
+ // Simulate blur to a related element with matching data-edit-action
208
+ const relatedTarget = document.createElement("button");
209
+ relatedTarget.dataset.editAction = "name";
210
+
211
+ act(() => {
212
+ result.current.handleBlur({ relatedTarget } as unknown as React.FocusEvent);
213
+ });
214
+
215
+ expect(onSave).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it("blur to unrelated element triggers save", () => {
219
+ const onSave = vi.fn();
220
+ const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
221
+ const { result } = renderHook(() => useEditableField(opts));
222
+
223
+ act(() => result.current.startEdit());
224
+ result.current.ignoreInitialBlurRef.current = false;
225
+ act(() => result.current.setDraft("new"));
226
+
227
+ act(() => {
228
+ result.current.handleBlur({ relatedTarget: null } as unknown as React.FocusEvent);
229
+ });
230
+
231
+ expect(onSave).toHaveBeenCalledWith("new");
232
+ });
233
+ });