@skyhook-io/k8s-ui 1.17.1 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -0
@@ -35,6 +35,12 @@ type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'levelSource' | 'isJson' | 'i
35
35
 
36
36
  interface UseLogBufferReturn {
37
37
  entries: LogEntry[]
38
+ isFollowing: boolean
39
+ retainedLineCount: number
40
+ missingLineCount: number
41
+ getRetainedEntries: () => LogEntry[]
42
+ pendingLineCount: number
43
+ setFollowing: (following: boolean) => void
38
44
  append: (entry: RawLogEntry) => void
39
45
  appendBatch: (entries: RawLogEntry[]) => void
40
46
  set: (entries: RawLogEntry[]) => void
@@ -42,7 +48,7 @@ interface UseLogBufferReturn {
42
48
  }
43
49
 
44
50
  export function useLogBuffer(): UseLogBufferReturn {
45
- const [entries, setEntries] = useState<LogEntry[]>([])
51
+ const [buffer, setBuffer] = useState<{ latest: LogEntry[]; reading: LogEntry[] | null }>({ latest: [], reading: null })
46
52
  const idCounter = useRef(0)
47
53
  const pendingRef = useRef<RawLogEntry[]>([])
48
54
  const rafRef = useRef<number | null>(null)
@@ -65,30 +71,35 @@ export function useLogBuffer(): UseLogBufferReturn {
65
71
  const batch = pendingRef.current
66
72
  if (batch.length === 0) return
67
73
  pendingRef.current = []
68
-
69
- setEntries(prev => {
70
- const enriched = batch.map(enrichEntry)
71
- const combined = [...prev, ...enriched]
72
- if (combined.length > MAX_BUFFER_SIZE) {
73
- return combined.slice(combined.length - MAX_BUFFER_SIZE)
74
- }
75
- return combined
74
+ const enriched = batch.map(enrichEntry)
75
+
76
+ setBuffer(prev => {
77
+ const combined = [...prev.latest, ...enriched]
78
+ const latest = combined.length > MAX_BUFFER_SIZE ? combined.slice(-MAX_BUFFER_SIZE) : combined
79
+ // Keep appending while the reader's window fits. Once eviction begins,
80
+ // retain that window rather than replacing the lines being read.
81
+ const reading = prev.reading === null ? null
82
+ : prev.reading === prev.latest && combined.length <= MAX_BUFFER_SIZE ? latest : prev.reading
83
+ return { latest, reading }
76
84
  })
77
85
  }, [enrichEntry])
78
86
 
79
87
  const append = useCallback((entry: RawLogEntry) => {
80
88
  pendingRef.current.push(entry)
89
+ // Hidden tabs can stop delivering animation frames while logs still arrive.
90
+ if (pendingRef.current.length >= MAX_BUFFER_SIZE) {
91
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
92
+ flushPending()
93
+ return
94
+ }
81
95
  if (rafRef.current === null) {
82
96
  rafRef.current = requestAnimationFrame(flushPending)
83
97
  }
84
98
  }, [flushPending])
85
99
 
86
100
  const appendBatch = useCallback((batch: RawLogEntry[]) => {
87
- pendingRef.current.push(...batch)
88
- if (rafRef.current === null) {
89
- rafRef.current = requestAnimationFrame(flushPending)
90
- }
91
- }, [flushPending])
101
+ for (const entry of batch) append(entry)
102
+ }, [append])
92
103
 
93
104
  const set = useCallback((rawEntries: RawLogEntry[]) => {
94
105
  // Cancel any pending RAF
@@ -100,9 +111,9 @@ export function useLogBuffer(): UseLogBufferReturn {
100
111
 
101
112
  const enriched = rawEntries.map(enrichEntry)
102
113
  if (enriched.length > MAX_BUFFER_SIZE) {
103
- setEntries(enriched.slice(enriched.length - MAX_BUFFER_SIZE))
114
+ setBuffer({ latest: enriched.slice(enriched.length - MAX_BUFFER_SIZE), reading: null })
104
115
  } else {
105
- setEntries(enriched)
116
+ setBuffer({ latest: enriched, reading: null })
106
117
  }
107
118
  }, [enrichEntry])
108
119
 
@@ -112,8 +123,27 @@ export function useLogBuffer(): UseLogBufferReturn {
112
123
  rafRef.current = null
113
124
  }
114
125
  pendingRef.current = []
115
- setEntries([])
126
+ setBuffer({ latest: [], reading: null })
127
+ }, [])
128
+
129
+ const setFollowing = useCallback((following: boolean) => {
130
+ setBuffer(prev => {
131
+ if (following) return prev.reading === null ? prev : { ...prev, reading: null }
132
+ return prev.reading === null ? { ...prev, reading: prev.latest } : prev
133
+ })
116
134
  }, [])
117
135
 
118
- return { entries, append, appendBatch, set, clear }
136
+ const entries = buffer.reading ?? buffer.latest
137
+ const pendingLineCount = (buffer.latest.at(-1)?.id ?? -1) - (entries.at(-1)?.id ?? -1)
138
+ const readingLastId = entries.at(-1)?.id ?? -1
139
+ const latestFirstId = buffer.latest[0]?.id ?? 0
140
+ const overlap = Math.max(0, readingLastId - latestFirstId + 1)
141
+ const retainedLineCount = buffer.reading === null ? buffer.latest.length : entries.length + buffer.latest.length - overlap
142
+ const missingLineCount = buffer.reading === null ? 0 : Math.max(0, latestFirstId - readingLastId - 1)
143
+ const getRetainedEntries = useCallback(() => {
144
+ if (buffer.reading === null || buffer.reading === buffer.latest) return buffer.latest
145
+ const lastId = buffer.reading.at(-1)?.id ?? -1
146
+ return [...buffer.reading, ...buffer.latest.filter(entry => entry.id > lastId)]
147
+ }, [buffer])
148
+ return { entries, retainedLineCount, missingLineCount, getRetainedEntries, isFollowing: buffer.reading === null, pendingLineCount, setFollowing, append, appendBatch, set, clear }
119
149
  }
@@ -54,6 +54,7 @@ export function useLogSearch(
54
54
  virtuosoRef: React.RefObject<VirtuosoHandle | null>,
55
55
  /** Id of the record an entry belongs to (its first line's id); defaults to the entry itself */
56
56
  recordIdOf?: (entry: LogEntry) => number,
57
+ onNavigate?: () => void,
57
58
  ): UseLogSearchReturn {
58
59
  const [query, setQuery] = useState('')
59
60
  const [isRegex, setIsRegex] = useState(false)
@@ -122,6 +123,7 @@ export function useLogSearch(
122
123
  const scrollToMatch = useCallback((matchIdx: number) => {
123
124
  if (matchIdx < 0 || matchIdx >= matchIndices.length) return
124
125
  if (mode === 'hide') return
126
+ onNavigate?.()
125
127
  if (mode === 'only') {
126
128
  // The list holds whole matching records, so find the matched line within it
127
129
  const index = filteredIndexById?.get(entries[matchIndices[matchIdx]].id)
@@ -139,7 +141,7 @@ export function useLogSearch(
139
141
  behavior: 'smooth',
140
142
  })
141
143
  }
142
- }, [entries, matchIndices, mode, filteredIndexById, virtuosoRef])
144
+ }, [entries, matchIndices, mode, filteredIndexById, virtuosoRef, onNavigate])
143
145
 
144
146
  const goToNext = useCallback(() => {
145
147
  if (matchIndices.length === 0 || mode === 'hide') return
@@ -1,8 +1,8 @@
1
1
  // @vitest-environment jsdom
2
- import { act, type ReactNode } from 'react'
2
+ import { act, createRef, useState, type ReactNode } from 'react'
3
3
  import { createRoot, type Root } from 'react-dom/client'
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
- import { NamespacePicker, type NamespacePickerProps, type NamespaceScopeView } from './NamespacePicker'
5
+ import { NamespacePicker, type NamespacePickerHandle, type NamespacePickerProps, type NamespaceScopeView } from './NamespacePicker'
6
6
 
7
7
  vi.mock('../ui/Tooltip', () => ({
8
8
  Tooltip: ({ children, content }: { children: ReactNode; content: ReactNode }) => (
@@ -138,3 +138,237 @@ describe('NamespacePicker incomplete-list notice', () => {
138
138
  expect(onApply).toHaveBeenCalledWith(['team-a'])
139
139
  })
140
140
  })
141
+
142
+ describe('NamespacePicker Only button', () => {
143
+ const scope = { ...baseScope, actives: ['team-a'], accessibleNamespaces: ['team-a', 'team-b', 'team-c'] }
144
+
145
+ const triggerButton = () =>
146
+ document.querySelector<HTMLButtonElement>('button[aria-label="Switch active namespaces"]')!
147
+
148
+ async function open(onApply = vi.fn()) {
149
+ await act(async () => {
150
+ root.render(<NamespacePicker scope={scope} onApply={onApply} />)
151
+ })
152
+ await act(async () => { triggerButton().click() })
153
+ return onApply
154
+ }
155
+
156
+ const onlyButton = (ns: string) =>
157
+ document.querySelector<HTMLButtonElement>(`button[aria-label="Show only ${ns}"]`)!
158
+ const row = (ns: string) =>
159
+ Array.from(document.querySelectorAll('label')).find(l => l.textContent?.includes(ns))!
160
+ const checkbox = (ns: string) => row(ns).querySelector<HTMLInputElement>('input[type="checkbox"]')!
161
+ const doneButton = () =>
162
+ Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Done')!
163
+ const isOpen = () => document.querySelector('input[type="checkbox"]') !== null
164
+
165
+ it('puts an Only button on every row', async () => {
166
+ await open()
167
+ for (const ns of ['team-a', 'team-b', 'team-c']) expect(onlyButton(ns).textContent).toBe('Only')
168
+ })
169
+
170
+ it('applies just that namespace and closes the dropdown', async () => {
171
+ const onApply = await open()
172
+ expect(isOpen()).toBe(true)
173
+ await act(async () => { onlyButton('team-c').click() })
174
+
175
+ expect(onApply).toHaveBeenCalledTimes(1)
176
+ expect(onApply).toHaveBeenCalledWith(['team-c'])
177
+ expect(isOpen()).toBe(false)
178
+ })
179
+
180
+ it('replaces unsaved checkbox toggles', async () => {
181
+ const onApply = await open()
182
+ await act(async () => { checkbox('team-b').click() })
183
+ await act(async () => { onlyButton('team-c').click() })
184
+
185
+ expect(onApply).toHaveBeenCalledWith(['team-c'])
186
+ })
187
+
188
+ it('does not re-apply when that namespace is already the only one active', async () => {
189
+ const onApply = await open()
190
+ await act(async () => { onlyButton('team-a').click() })
191
+
192
+ expect(onApply).not.toHaveBeenCalled()
193
+ expect(isOpen()).toBe(false)
194
+ })
195
+
196
+ it('keeps the name and checkbox as add/remove toggles', async () => {
197
+ const onApply = await open()
198
+ await act(async () => { checkbox('team-b').click() })
199
+ expect(checkbox('team-b').checked).toBe(true)
200
+ expect(onApply).not.toHaveBeenCalled()
201
+ expect(isOpen()).toBe(true)
202
+
203
+ const name = Array.from(row('team-c').querySelectorAll('span')).find(s => s.textContent === 'team-c')!
204
+ await act(async () => { name.click() })
205
+ expect(checkbox('team-c').checked).toBe(true)
206
+ expect(onApply).not.toHaveBeenCalled()
207
+ expect(isOpen()).toBe(true)
208
+
209
+ await act(async () => { doneButton().click() })
210
+ expect(onApply).toHaveBeenCalledWith(['team-a', 'team-b', 'team-c'])
211
+ })
212
+
213
+ it('keeps the button out of the row label', async () => {
214
+ await open()
215
+ expect(document.querySelector('label button')).toBeNull()
216
+ expect(onlyButton('team-b').parentElement).toBe(row('team-b').parentElement)
217
+ })
218
+
219
+ it('is reachable by keyboard, with no tabindex taking it out of the tab order', async () => {
220
+ await open()
221
+ const only = onlyButton('team-b')
222
+ expect(only.tabIndex).toBe(0)
223
+ only.focus()
224
+ expect(document.activeElement).toBe(only)
225
+ })
226
+
227
+ it.each(['Only', 'Done', 'Clear all'])('reopens from active namespaces after an unacknowledged %s apply', async (action) => {
228
+ const onApply = await open()
229
+ if (action === 'Only') {
230
+ await act(async () => { onlyButton('team-c').click() })
231
+ } else if (action === 'Done') {
232
+ await act(async () => { checkbox('team-b').click() })
233
+ await act(async () => { doneButton().click() })
234
+ } else {
235
+ const clear = document.querySelector<HTMLButtonElement>('button[aria-label="Clear namespace selection"]')!
236
+ await act(async () => { clear.click() })
237
+ }
238
+ expect(onApply).toHaveBeenCalledTimes(1)
239
+ expect(isOpen()).toBe(false)
240
+
241
+ await act(async () => { triggerButton().click() })
242
+ expect(checkbox('team-a').checked).toBe(true)
243
+ expect(checkbox('team-b').checked).toBe(false)
244
+ expect(checkbox('team-c').checked).toBe(false)
245
+ await act(async () => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) })
246
+ expect(onApply).toHaveBeenCalledTimes(1)
247
+ })
248
+
249
+ it('also refreshes the draft when the keyboard shortcut opens the picker', async () => {
250
+ const ref = createRef<NamespacePickerHandle>()
251
+ const onApply = vi.fn()
252
+ await act(async () => { root.render(<NamespacePicker ref={ref} scope={scope} onApply={onApply} />) })
253
+ await act(async () => { ref.current?.open() })
254
+ await act(async () => { onlyButton('team-c').click() })
255
+ await act(async () => { ref.current?.open() })
256
+ expect(checkbox('team-a').checked).toBe(true)
257
+ expect(checkbox('team-c').checked).toBe(false)
258
+ await act(async () => { doneButton().click() })
259
+ expect(onApply).toHaveBeenCalledTimes(1)
260
+ })
261
+
262
+ it('keeps current edits if the open shortcut is used while the menu is already open', async () => {
263
+ const ref = createRef<NamespacePickerHandle>()
264
+ const onApply = vi.fn()
265
+ await act(async () => { root.render(<NamespacePicker ref={ref} scope={scope} onApply={onApply} />) })
266
+ await act(async () => { ref.current?.open() })
267
+ await act(async () => { checkbox('team-b').click() })
268
+ await act(async () => { ref.current?.open() })
269
+ expect(checkbox('team-a').checked).toBe(true)
270
+ expect(checkbox('team-b').checked).toBe(true)
271
+ expect(onApply).not.toHaveBeenCalled()
272
+ })
273
+
274
+ describe('returning focus after the switch', () => {
275
+ let setPending: (pending: boolean) => void
276
+
277
+ function Host({ onApply, pendingOnApply = true }: { onApply: (namespaces: string[]) => void; pendingOnApply?: boolean }) {
278
+ const [pending, setPendingState] = useState(false)
279
+ setPending = setPendingState
280
+ return (
281
+ <NamespacePicker
282
+ scope={scope}
283
+ pending={pending}
284
+ onApply={namespaces => {
285
+ if (pendingOnApply) setPendingState(true)
286
+ onApply(namespaces)
287
+ }}
288
+ />
289
+ )
290
+ }
291
+
292
+ async function openAndPressOnly(ns: string, pendingOnApply = true) {
293
+ const onApply = vi.fn()
294
+ await act(async () => { root.render(<Host onApply={onApply} pendingOnApply={pendingOnApply} />) })
295
+ await act(async () => { triggerButton().click() })
296
+ const only = onlyButton(ns)
297
+ only.focus()
298
+ await act(async () => { only.click() })
299
+ return onApply
300
+ }
301
+
302
+ // A browser blurs a focused button the moment it becomes disabled; jsdom does not.
303
+ async function startPending() {
304
+ ;(document.activeElement as HTMLElement | null)?.blur()
305
+ await act(async () => { setPending(true) })
306
+ }
307
+
308
+ it('puts focus back on the trigger when the switch is instant', async () => {
309
+ await open()
310
+ const only = onlyButton('team-a')
311
+ only.focus()
312
+ await act(async () => { only.click() })
313
+
314
+ expect(document.activeElement).toBe(triggerButton())
315
+ })
316
+
317
+ it('waits for the pending switch to finish, because the trigger is inert until then', async () => {
318
+ await openAndPressOnly('team-c')
319
+ expect(triggerButton().disabled).toBe(true)
320
+ expect(document.activeElement).toBe(document.body)
321
+
322
+ await act(async () => { setPending(false) })
323
+ expect(document.activeElement).toBe(triggerButton())
324
+ })
325
+
326
+ it('restores focus again when the host only reports pending after the menu has closed', async () => {
327
+ await openAndPressOnly('team-c', false)
328
+ expect(document.activeElement).toBe(triggerButton())
329
+
330
+ await startPending()
331
+ expect(triggerButton().disabled).toBe(true)
332
+ expect(document.activeElement).toBe(document.body)
333
+
334
+ await act(async () => { setPending(false) })
335
+ expect(document.activeElement).toBe(triggerButton())
336
+ })
337
+
338
+ it('leaves focus alone when the user moved elsewhere during the switch', async () => {
339
+ const elsewhere = document.createElement('input')
340
+ document.body.appendChild(elsewhere)
341
+ await openAndPressOnly('team-c')
342
+ await act(async () => { elsewhere.focus() })
343
+
344
+ await act(async () => { setPending(false) })
345
+ expect(document.activeElement).toBe(elsewhere)
346
+ })
347
+
348
+ it('leaves focus alone when the user clicked elsewhere during the switch', async () => {
349
+ await openAndPressOnly('team-c')
350
+ await act(async () => { document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) })
351
+
352
+ await act(async () => { setPending(false) })
353
+ expect(document.activeElement).toBe(document.body)
354
+ })
355
+
356
+ it('does not claim focus for a later switch once the user has moved on', async () => {
357
+ const elsewhere = document.createElement('input')
358
+ document.body.appendChild(elsewhere)
359
+ await openAndPressOnly('team-c', false)
360
+ await act(async () => { elsewhere.focus() })
361
+ await act(async () => { elsewhere.blur() })
362
+
363
+ await startPending()
364
+ await act(async () => { setPending(false) })
365
+ expect(document.activeElement).toBe(document.body)
366
+ })
367
+
368
+ it('does not move focus for other ways of closing', async () => {
369
+ await open()
370
+ await act(async () => { doneButton().click() })
371
+ expect(document.activeElement).toBe(document.body)
372
+ })
373
+ })
374
+ })
@@ -82,7 +82,8 @@ export interface NamespacePickerProps {
82
82
  *
83
83
  * Selection model: the dropdown keeps a draft Set<string>; toggling rows
84
84
  * mutates the draft locally; closing the dropdown applies the draft in a single
85
- * onApply. "Clear all" applies immediately and closes.
85
+ * onApply. "Clear all" applies immediately and closes, as does a row's "Only"
86
+ * button, which switches to that namespace alone.
86
87
  */
87
88
  export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePickerProps>(function NamespacePicker(
88
89
  { scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label, limitedListHelp },
@@ -95,6 +96,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
95
96
 
96
97
  const triggerRef = useRef<HTMLButtonElement>(null)
97
98
  const dropdownRef = useRef<HTMLDivElement>(null)
99
+ const [restoreFocus, setRestoreFocus] = useState(false)
98
100
 
99
101
  const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
100
102
  const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
@@ -149,12 +151,34 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
149
151
  applySelection(draft)
150
152
  }, [applySelection, draft])
151
153
 
152
- useImperativeHandle(ref, () => ({
153
- open: () => {
154
- if (disabled || loading || pending) return
155
- setIsOpen(true)
156
- },
157
- }), [disabled, loading, pending])
154
+ const openPicker = useCallback(() => {
155
+ if (isOpen || disabled || loading || pending) return
156
+ setDraft(new Set(scopeActives))
157
+ setIsOpen(true)
158
+ }, [isOpen, disabled, loading, pending, scopeActives])
159
+
160
+ useImperativeHandle(ref, () => ({ open: openPicker }), [openPicker])
161
+
162
+ // Disabling the trigger while a switch is pending drops its focus, so this stays armed through that cycle.
163
+ useEffect(() => {
164
+ if (!restoreFocus || isOpen || disabled || loading || pending) return
165
+ const active = document.activeElement
166
+ if (!active || active === document.body) triggerRef.current?.focus()
167
+ }, [restoreFocus, isOpen, disabled, loading, pending])
168
+
169
+ useEffect(() => {
170
+ if (!restoreFocus) return
171
+ const onFocusIn = (e: FocusEvent) => {
172
+ if (e.target !== triggerRef.current) setRestoreFocus(false)
173
+ }
174
+ const onPointerDown = () => setRestoreFocus(false)
175
+ document.addEventListener('focusin', onFocusIn)
176
+ document.addEventListener('pointerdown', onPointerDown)
177
+ return () => {
178
+ document.removeEventListener('focusin', onFocusIn)
179
+ document.removeEventListener('pointerdown', onPointerDown)
180
+ }
181
+ }, [restoreFocus])
158
182
 
159
183
  useEffect(() => {
160
184
  if (!isOpen) return
@@ -198,6 +222,15 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
198
222
  setDraft(next)
199
223
  }
200
224
 
225
+ const selectOnly = (ns: string) => {
226
+ const next = new Set([ns])
227
+ setRestoreFocus(true)
228
+ setDraft(next)
229
+ setIsOpen(false)
230
+ setSearch('')
231
+ applySelection(next)
232
+ }
233
+
201
234
  const clearAll = () => {
202
235
  if (scope.cacheScoped) return
203
236
  setDraft(new Set())
@@ -255,7 +288,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
255
288
  >
256
289
  <button
257
290
  ref={triggerRef}
258
- onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
291
+ onClick={() => !isDisabled && (isOpen ? closeAndApply() : openPicker())}
259
292
  disabled={isDisabled}
260
293
  className={
261
294
  variant === 'segment'
@@ -350,6 +383,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
350
383
  onSelectionChange={setDraft}
351
384
  onClearAll={clearAll}
352
385
  onDone={closeAndApply}
386
+ onSelectOnly={selectOnly}
353
387
  search={search}
354
388
  onSearchChange={setSearch}
355
389
  searchPlaceholder="Filter namespaces"
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { ColumnPickerList } from './ColumnPickerList'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ describe('ColumnPickerList keyboard focus', () => {
10
+ it.each([
11
+ ['namespace', 'ArrowUp'],
12
+ ['age', 'ArrowDown'],
13
+ ])('does not restore stale focus after repeated boundary moves on %s', (key, direction) => {
14
+ const host = document.createElement('div')
15
+ document.body.append(host)
16
+ const root = createRoot(host)
17
+ const onReorder = vi.fn()
18
+ const columns = ['name', 'namespace', 'age'].map(key => ({ key, label: key }))
19
+ const render = () => act(() => root.render(<ColumnPickerList
20
+ columns={columns}
21
+ visibleColumns={new Set(['name', 'namespace', 'age'])}
22
+ customColumnKeys={new Set()}
23
+ onToggle={() => {}}
24
+ onRemoveCustom={() => {}}
25
+ onReorder={onReorder}
26
+ />))
27
+ try {
28
+ render()
29
+ const inputs = [...host.querySelectorAll<HTMLInputElement>('input')]
30
+ const current = inputs[columns.findIndex(column => column.key === key)]
31
+ const other = inputs[key === 'namespace' ? 2 : 1]
32
+ if (!current || !other) throw new Error('Expected both movable checkboxes')
33
+ current.focus()
34
+ for (let i = 0; i < 3; i++) {
35
+ act(() => current.dispatchEvent(new KeyboardEvent('keydown', { key: direction, altKey: true, bubbles: true })))
36
+ }
37
+ other.focus()
38
+ render()
39
+ expect(document.activeElement).toBe(other)
40
+ expect(onReorder).not.toHaveBeenCalled()
41
+ } finally {
42
+ act(() => root.unmount())
43
+ host.remove()
44
+ }
45
+ })
46
+ })