@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,135 @@
1
+ @use '../../styles/mixins' as *;
2
+
3
+ // Edit wrapper that supports wrapping hint/error below the input
4
+ .editFieldWrapper {
5
+ display: flex;
6
+ flex-wrap: wrap;
7
+ flex: 1;
8
+ min-width: 0;
9
+ align-items: center;
10
+ gap: var(--space-xs);
11
+ }
12
+
13
+ .editInput {
14
+ @include input-field;
15
+ font-size: var(--font-size-sm);
16
+ padding: 2px var(--space-xs);
17
+ flex: 1;
18
+ min-width: 0;
19
+ }
20
+
21
+ .editTextarea {
22
+ @include input-field;
23
+ font-size: var(--font-size-sm);
24
+ padding: var(--space-xs) var(--space-sm);
25
+ flex: 1;
26
+ min-width: 0;
27
+ resize: vertical;
28
+ min-height: 60px;
29
+ font-family: inherit;
30
+ line-height: 1.5;
31
+ }
32
+
33
+ .editSelect {
34
+ @include input-field;
35
+ font-size: var(--font-size-sm);
36
+ padding: 2px var(--space-xs);
37
+ flex: 1;
38
+ min-width: 0;
39
+ cursor: pointer;
40
+ }
41
+
42
+ // Validation error message
43
+ .editError {
44
+ font-size: 10px;
45
+ color: var(--accent-red);
46
+ margin-top: 2px;
47
+ flex-basis: 100%;
48
+ }
49
+
50
+ // Red border for invalid inputs
51
+ .editInputInvalid {
52
+ border-color: var(--accent-red) !important;
53
+ box-shadow: 0 0 0 1px var(--accent-red-dim);
54
+ }
55
+
56
+ // Keyboard hint shown below editing inputs
57
+ .editHint {
58
+ font-size: 10px;
59
+ color: var(--text-tertiary);
60
+ opacity: 0.7;
61
+ margin-top: 2px;
62
+ font-family: var(--font-mono);
63
+ flex-basis: 100%;
64
+ }
65
+
66
+ // Unsaved dot indicator
67
+ .unsavedDot {
68
+ width: 6px;
69
+ height: 6px;
70
+ border-radius: 50%;
71
+ background: var(--accent-yellow);
72
+ flex-shrink: 0;
73
+ }
74
+
75
+ // Click-to-edit wrapper — makes the whole value area clickable
76
+ .metaValueClickable {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ gap: var(--space-xs);
80
+ cursor: pointer;
81
+ border-radius: var(--radius-sm);
82
+ padding: 1px var(--space-xs);
83
+ margin: -1px calc(-1 * var(--space-xs));
84
+ transition: background var(--transition-fast);
85
+ background: none;
86
+ border: none;
87
+ text-align: left;
88
+ color: inherit;
89
+ font: inherit;
90
+
91
+ &:hover {
92
+ background: var(--bg-overlay);
93
+ }
94
+ }
95
+
96
+ // Pencil icon with opacity transition
97
+ .editButton {
98
+ background: none;
99
+ border: none;
100
+ color: var(--text-tertiary);
101
+ cursor: pointer;
102
+ padding: 2px;
103
+ font-size: var(--font-size-sm);
104
+ opacity: 0.4;
105
+ transition: opacity var(--transition-fast);
106
+ flex-shrink: 0;
107
+
108
+ &:hover {
109
+ opacity: 1;
110
+ color: var(--accent-green);
111
+ }
112
+ }
113
+
114
+ // Italic gray placeholder for empty values
115
+ .metaPlaceholder {
116
+ color: var(--text-tertiary);
117
+ font-style: italic;
118
+ }
119
+
120
+ // Checkbox + label styling
121
+ .worktreeToggle {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: var(--space-sm);
125
+ font-size: var(--font-size-sm);
126
+ color: var(--text-secondary);
127
+ cursor: pointer;
128
+
129
+ input[type="checkbox"] {
130
+ accent-color: var(--accent-green);
131
+ width: 16px;
132
+ height: 16px;
133
+ cursor: pointer;
134
+ }
135
+ }
@@ -0,0 +1,164 @@
1
+ import { useCallback, useEffect, useRef, type JSX, type ReactNode } from "react";
2
+ import { useEditableField } from "./useEditableField.js";
3
+ import styles from "./EditableField.module.scss";
4
+
5
+ /** A single option in the select dropdown. */
6
+ export interface SelectOption {
7
+ value: string;
8
+ label: string;
9
+ }
10
+
11
+ /** Props for EditableSelect. */
12
+ export interface EditableSelectProps {
13
+ /** Current persisted value. */
14
+ value: string;
15
+ /** Called when the user selects a new value. Required in edit mode. */
16
+ onSave: (value: string) => void;
17
+ /** "edit" (default) for click-to-edit, "create" for always-visible. */
18
+ mode?: "edit" | "create";
19
+ /** Available options for the dropdown. */
20
+ options: SelectOption[];
21
+ /** Unique field identifier for coordination. */
22
+ fieldId?: string;
23
+ /** Which field is currently being edited (parent coordination). */
24
+ activeFieldId?: string | null; // eslint-disable-line @rushstack/no-new-null
25
+ /** Callback to tell the parent which field is active. */
26
+ onActivate?: (fieldId: string | null) => void; // eslint-disable-line @rushstack/no-new-null
27
+ /** Called on change in create mode. */
28
+ onChange?: (value: string) => void;
29
+ /** Custom display renderer for the selected value. */
30
+ renderDisplay?: (value: string) => ReactNode | undefined;
31
+ /** Placeholder text when no value is selected. */
32
+ placeholder?: string;
33
+ /** Accessible label for the select. */
34
+ ariaLabel?: string;
35
+ /** Base test ID — gets `-select` / `-button` suffixes appended. */
36
+ "data-testid"?: string;
37
+ }
38
+
39
+ /** Reusable click-to-edit select dropdown. */
40
+ export function EditableSelect(props: EditableSelectProps): JSX.Element {
41
+ const {
42
+ value,
43
+ onSave,
44
+ mode = "edit",
45
+ options,
46
+ fieldId = "select",
47
+ activeFieldId,
48
+ onActivate,
49
+ onChange,
50
+ renderDisplay,
51
+ placeholder,
52
+ ariaLabel,
53
+ "data-testid": testId,
54
+ } = props;
55
+
56
+ const selectRef = useRef<HTMLSelectElement>(null);
57
+
58
+ const field = useEditableField({
59
+ value,
60
+ onSave,
61
+ fieldId,
62
+ activeFieldId,
63
+ onActivate,
64
+ enterToSave: false,
65
+ trimOnSave: false,
66
+ });
67
+
68
+ // Auto-focus when entering edit mode
69
+ useEffect(() => {
70
+ if (field.isEditing) {
71
+ const timer = window.setTimeout(() => {
72
+ selectRef.current?.focus();
73
+ }, 0);
74
+ return () => window.clearTimeout(timer);
75
+ }
76
+ }, [field.isEditing]);
77
+
78
+ /** Select saves immediately on change and exits edit mode. */
79
+ const handleSelectChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
80
+ const newValue = e.target.value;
81
+ field.ignoreInitialBlurRef.current = false;
82
+ if (newValue !== value) {
83
+ onSave(newValue);
84
+ }
85
+ field.cancelEdit();
86
+ }, [value, onSave, field]);
87
+
88
+ /** Blur just cancels (no auto-save for selects). */
89
+ const handleSelectBlur = useCallback((event: React.FocusEvent) => {
90
+ if (field.ignoreInitialBlurRef.current) {
91
+ field.ignoreInitialBlurRef.current = false;
92
+ return;
93
+ }
94
+ if (
95
+ event.relatedTarget instanceof HTMLElement &&
96
+ event.relatedTarget.dataset.editAction === fieldId
97
+ ) {
98
+ return;
99
+ }
100
+ field.cancelEdit();
101
+ }, [fieldId, field]);
102
+
103
+ // Create mode: always show select
104
+ if (mode === "create") {
105
+ return (
106
+ <select
107
+ className={styles.editSelect}
108
+ value={value}
109
+ onChange={(e) => onChange?.(e.target.value)}
110
+ aria-label={ariaLabel}
111
+ data-testid={testId ? `${testId}-select` : undefined}
112
+ >
113
+ {options.map((opt) => (
114
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
115
+ ))}
116
+ </select>
117
+ );
118
+ }
119
+
120
+ // Edit mode: show select dropdown
121
+ if (field.isEditing) {
122
+ return (
123
+ <select
124
+ ref={selectRef}
125
+ className={styles.editSelect}
126
+ value={field.draft}
127
+ onChange={handleSelectChange}
128
+ onBlur={handleSelectBlur}
129
+ title={ariaLabel}
130
+ aria-label={ariaLabel}
131
+ data-testid={testId ? `${testId}-select` : undefined}
132
+ >
133
+ {options.map((opt) => (
134
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
135
+ ))}
136
+ </select>
137
+ );
138
+ }
139
+
140
+ // Display mode
141
+ const displayContent = renderDisplay?.(value);
142
+ const selectedLabel = options.find((o) => o.value === value)?.label;
143
+ return (
144
+ <button
145
+ type="button"
146
+ className={styles.metaValueClickable}
147
+ onClick={() => field.startEdit()}
148
+ title="Click to change"
149
+ aria-label={ariaLabel}
150
+ data-testid={testId ? `${testId}-button` : undefined}
151
+ >
152
+ {displayContent !== undefined ? displayContent : (
153
+ selectedLabel ? (
154
+ <span>{selectedLabel}</span>
155
+ ) : (
156
+ <span className={styles.metaPlaceholder}>{placeholder || "None"}</span>
157
+ )
158
+ )}
159
+ <span className={styles.editButton} aria-hidden="true">
160
+ &#x270F;&#xFE0F;
161
+ </span>
162
+ </button>
163
+ );
164
+ }
@@ -0,0 +1,50 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn } from "@storybook/test";
3
+ import { EditableTextArea } from "./EditableTextArea.js";
4
+
5
+ const meta: Meta<typeof EditableTextArea> = {
6
+ title: "Primitives/Editable/EditableTextArea",
7
+ tags: ["autodocs"],
8
+ component: EditableTextArea,
9
+ args: {
10
+ value: "Multi-line\ntext content",
11
+ onSave: fn(),
12
+ "data-testid": "test-area",
13
+ },
14
+ };
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ /** Display mode shows the value with an edit button. */
19
+ export const DisplayMode: Story = {
20
+ play: async ({ canvas }) => {
21
+ const button = canvas.getByTestId("test-area-button");
22
+ await expect(button).toBeInTheDocument();
23
+ await expect(button).toHaveAttribute("role", "button");
24
+ },
25
+ };
26
+
27
+ /** Display button is keyboard-accessible (tabIndex, role, focusable). */
28
+ export const KeyboardAccessible: Story = {
29
+ play: async ({ canvas }) => {
30
+ const button = canvas.getByTestId("test-area-button");
31
+ await expect(button).toHaveAttribute("role", "button");
32
+ await expect(button).toHaveAttribute("tabindex", "0");
33
+
34
+ button.focus();
35
+ await expect(button).toHaveFocus();
36
+ },
37
+ };
38
+
39
+ /** Create mode renders as an always-visible textarea. */
40
+ export const CreateMode: Story = {
41
+ args: {
42
+ mode: "create",
43
+ value: "",
44
+ placeholder: "Enter description",
45
+ },
46
+ play: async ({ canvas }) => {
47
+ const textarea = canvas.getByTestId("test-area-input");
48
+ await expect(textarea).toBeInTheDocument();
49
+ },
50
+ };
@@ -0,0 +1,148 @@
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 EditableTextArea. */
6
+ export interface EditableTextAreaProps {
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., Markdown). */
24
+ renderDisplay?: (value: string) => ReactNode | undefined;
25
+ /** Placeholder text shown when empty. */
26
+ placeholder?: string;
27
+ /** Accessible label for the textarea. */
28
+ ariaLabel?: string;
29
+ /** Base test ID — gets `-input` / `-button` suffixes appended. */
30
+ "data-testid"?: string;
31
+ }
32
+
33
+ /** Reusable click-to-edit textarea field. */
34
+ export function EditableTextArea(props: EditableTextAreaProps): JSX.Element {
35
+ const {
36
+ value,
37
+ onSave,
38
+ validate,
39
+ mode = "edit",
40
+ fieldId = "textarea",
41
+ activeFieldId,
42
+ onActivate,
43
+ onChange,
44
+ renderDisplay,
45
+ placeholder,
46
+ ariaLabel,
47
+ "data-testid": testId,
48
+ } = props;
49
+
50
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
51
+
52
+ const field = useEditableField({
53
+ value,
54
+ onSave,
55
+ validate,
56
+ fieldId,
57
+ activeFieldId,
58
+ onActivate,
59
+ enterToSave: false,
60
+ trimOnSave: false,
61
+ });
62
+
63
+ // Auto-focus when entering edit mode
64
+ useEffect(() => {
65
+ if (field.isEditing) {
66
+ const timer = window.setTimeout(() => {
67
+ textareaRef.current?.focus();
68
+ }, 0);
69
+ return () => window.clearTimeout(timer);
70
+ }
71
+ }, [field.isEditing]);
72
+
73
+ // Create mode: always show textarea, no blur-to-save
74
+ if (mode === "create") {
75
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
76
+ onChange?.(e.target.value);
77
+ };
78
+
79
+ const validationError = validate?.(value);
80
+
81
+ return (
82
+ <div className={styles.editFieldWrapper}>
83
+ <textarea
84
+ className={`${styles.editTextarea} ${validationError ? styles.editInputInvalid : ""}`}
85
+ value={value}
86
+ onChange={handleChange}
87
+ placeholder={placeholder}
88
+ aria-label={ariaLabel}
89
+ data-testid={testId ? `${testId}-input` : undefined}
90
+ />
91
+ {validationError && (
92
+ <span className={styles.editError} data-testid="edit-error">{validationError}</span>
93
+ )}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ // Edit mode: toggle between display and textarea
99
+ if (field.isEditing) {
100
+ return (
101
+ <div className={styles.editFieldWrapper}>
102
+ <textarea
103
+ ref={textareaRef}
104
+ className={`${styles.editTextarea} ${field.error ? styles.editInputInvalid : ""}`}
105
+ value={field.draft}
106
+ onChange={(e) => field.setDraft(e.target.value)}
107
+ onBlur={field.handleBlur}
108
+ onKeyDown={field.handleKeyDown}
109
+ title={ariaLabel}
110
+ aria-label={ariaLabel}
111
+ data-testid={testId ? `${testId}-input` : undefined}
112
+ />
113
+ {field.isDirty && <span className={styles.unsavedDot} title="Unsaved changes" />}
114
+ {field.error && (
115
+ <span className={styles.editError} data-testid="edit-error">{field.error}</span>
116
+ )}
117
+ <span className={styles.editHint}>Tab to save &middot; Esc to cancel</span>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ // Display mode — uses <span role="button"> to avoid nested interactive elements
123
+ // when renderDisplay returns links or block-level content (e.g., Markdown)
124
+ const displayContent = renderDisplay?.(value);
125
+ return (
126
+ <span
127
+ role="button"
128
+ tabIndex={0}
129
+ className={styles.metaValueClickable}
130
+ onClick={() => field.startEdit()}
131
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); field.startEdit(); } }}
132
+ title="Click to edit"
133
+ aria-label={ariaLabel}
134
+ data-testid={testId ? `${testId}-button` : undefined}
135
+ >
136
+ {displayContent !== undefined ? displayContent : (
137
+ value ? (
138
+ <span>{value}</span>
139
+ ) : (
140
+ <span className={styles.metaPlaceholder}>{placeholder || "None"}</span>
141
+ )
142
+ )}
143
+ <span className={styles.editButton} aria-hidden="true">
144
+ &#x270F;&#xFE0F;
145
+ </span>
146
+ </span>
147
+ );
148
+ }
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn } from "@storybook/test";
3
+ import { EditableTextField } from "./EditableTextField.js";
4
+
5
+ const meta: Meta<typeof EditableTextField> = {
6
+ title: "Primitives/Editable/EditableTextField",
7
+ tags: ["autodocs"],
8
+ component: EditableTextField,
9
+ args: {
10
+ value: "Hello World",
11
+ onSave: fn(),
12
+ "data-testid": "test-field",
13
+ },
14
+ };
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ /** Display mode shows the value with an edit button. */
19
+ export const DisplayMode: Story = {
20
+ play: async ({ canvas }) => {
21
+ await expect(canvas.getByText("Hello World")).toBeInTheDocument();
22
+ const button = canvas.getByTestId("test-field-button");
23
+ await expect(button).toBeInTheDocument();
24
+ await expect(button).toHaveAttribute("role", "button");
25
+ },
26
+ };
27
+
28
+ /** Display button is keyboard-accessible (tabIndex, role, focusable). */
29
+ export const KeyboardAccessible: Story = {
30
+ play: async ({ canvas }) => {
31
+ const button = canvas.getByTestId("test-field-button");
32
+ await expect(button).toHaveAttribute("role", "button");
33
+ await expect(button).toHaveAttribute("tabindex", "0");
34
+
35
+ button.focus();
36
+ await expect(button).toHaveFocus();
37
+ },
38
+ };
39
+
40
+ /** Empty value shows placeholder text. */
41
+ export const EmptyShowsPlaceholder: Story = {
42
+ args: {
43
+ value: "",
44
+ placeholder: "Enter a value",
45
+ },
46
+ play: async ({ canvas }) => {
47
+ await expect(canvas.getByText("Enter a value")).toBeInTheDocument();
48
+ },
49
+ };
50
+
51
+ /** Create mode renders as an always-visible input. */
52
+ export const CreateMode: Story = {
53
+ args: {
54
+ mode: "create",
55
+ value: "",
56
+ placeholder: "Enter title",
57
+ },
58
+ play: async ({ canvas }) => {
59
+ const input = canvas.getByTestId("test-field-input");
60
+ await expect(input).toBeInTheDocument();
61
+ },
62
+ };