@skyhook-io/k8s-ui 1.14.11 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +2 -1
  2. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  3. package/src/components/gitops/RollbackDialog.tsx +4 -3
  4. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  5. package/src/components/gitops/health-provenance.test.ts +1 -1
  6. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  7. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  8. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  9. package/src/components/resources/ResourcesView.tsx +4 -0
  10. package/src/components/resources/printer-columns.test.ts +1 -1
  11. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  12. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  13. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  14. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  15. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  16. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  17. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  18. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  19. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  20. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  21. package/src/components/resources/renderers/index.ts +2 -0
  22. package/src/components/resources/resource-utils-kueue.ts +14 -0
  23. package/src/components/resources/resource-utils-ray.ts +14 -4
  24. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  25. package/src/components/shared/ResourceActionsBar.tsx +4 -2
  26. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  27. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  28. package/src/components/shared/SetImageDialog.tsx +4 -2
  29. package/src/components/shared/revision-role-badges.test.ts +1 -1
  30. package/src/components/trace/reachGraphModel.test.ts +1 -0
  31. package/src/components/trace/reachInspector.test.ts +4 -9
  32. package/src/components/ui/ConfirmDialog.tsx +5 -3
  33. package/src/components/ui/DialogPortal.test.tsx +279 -0
  34. package/src/components/ui/DialogPortal.tsx +147 -9
  35. package/src/components/ui/YamlReview.tsx +4 -1
  36. package/src/components/ui/drawer-components.test.tsx +14 -1
  37. package/src/components/ui/drawer-components.tsx +50 -40
@@ -1,4 +1,10 @@
1
- import { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ type FocusEvent as ReactFocusEvent,
5
+ type KeyboardEvent as ReactKeyboardEvent,
6
+ type ReactNode,
7
+ } from 'react'
2
8
  import { createPortal } from 'react-dom'
3
9
  import { clsx } from 'clsx'
4
10
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
@@ -12,6 +18,36 @@ interface DialogPortalProps {
12
18
  className?: string
13
19
  /** Prevent closing via Escape / backdrop click (e.g. during async operation) */
14
20
  closable?: boolean
21
+ /** Id of the element that names the dialog (usually its title). Preferred over `ariaLabel`. */
22
+ ariaLabelledBy?: string
23
+ /** Accessible name when there is no visible title to reference. */
24
+ ariaLabel?: string
25
+ }
26
+
27
+ const FOCUS_GUARD = 'fixed top-0 left-0 w-px h-0 overflow-hidden outline-none'
28
+
29
+ const TABBABLE_SELECTOR = [
30
+ 'a[href]',
31
+ 'area[href]',
32
+ 'button:not([disabled])',
33
+ 'input:not([disabled]):not([type="hidden"])',
34
+ 'select:not([disabled])',
35
+ 'textarea:not([disabled])',
36
+ 'iframe',
37
+ '[contenteditable]:not([contenteditable="false"])',
38
+ '[tabindex]:not([tabindex="-1"])',
39
+ ].join(',')
40
+
41
+ // A CSS-hidden element (display:none, visibility:hidden) can't take focus, so
42
+ // treating it as an edge would let Tab slip past the real last element.
43
+ function isVisible(el: HTMLElement): boolean {
44
+ return typeof el.checkVisibility === 'function' ? el.checkVisibility({ visibilityProperty: true }) : true
45
+ }
46
+
47
+ function tabbableElements(panel: HTMLElement): HTMLElement[] {
48
+ return Array.from(panel.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter(
49
+ el => el.tabIndex >= 0 && !el.matches(':disabled') && !el.closest('[inert],[hidden]') && isVisible(el),
50
+ )
15
51
  }
16
52
 
17
53
  /**
@@ -19,14 +55,30 @@ interface DialogPortalProps {
19
55
  * Renders children inside a centered panel portaled to document.body, so it works
20
56
  * correctly even inside CSS-transformed containers (drawers, slide panels).
21
57
  *
58
+ * Focus moves into the panel on open (unless a child already took it, e.g. via
59
+ * `autoFocus`), Tab wraps at the panel's edges, and focus returns to the element
60
+ * that opened the dialog at logical close.
61
+ *
22
62
  * Usage:
23
- * <DialogPortal open={showDialog} onClose={() => setShowDialog(false)} className="w-80">
24
- * <h3>Title</h3>
63
+ * <DialogPortal open={showDialog} onClose={() => setShowDialog(false)} className="w-80" ariaLabelledBy={titleId}>
64
+ * <h3 id={titleId}>Title</h3>
25
65
  * <p>Content</p>
26
66
  * </DialogPortal>
27
67
  */
28
- export function DialogPortal({ open, onClose, children, className, closable = true }: DialogPortalProps) {
68
+ export function DialogPortal({
69
+ open,
70
+ onClose,
71
+ children,
72
+ className,
73
+ closable = true,
74
+ ariaLabelledBy,
75
+ ariaLabel,
76
+ }: DialogPortalProps) {
29
77
  const dialogRef = useRef<HTMLDivElement>(null)
78
+ // `el` is null when focus entered from nowhere restorable (body, or an
79
+ // element outside the panel that has since been removed).
80
+ const openerRef = useRef<{ el: HTMLElement | null } | null>(null)
81
+ const pendingRestoreRef = useRef(false)
30
82
  // The unmount window is the exit duration: shorter than the entrance, and
31
83
  // exactly what the panel transition below runs — a mismatch here cut the
32
84
  // close at two-thirds (200ms window under a 300ms panel).
@@ -44,15 +96,69 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
44
96
  }
45
97
  }
46
98
 
99
+ // Guards on either side of the panel catch focus leaving it, so inside the
100
+ // panel the browser's own Tab order applies — scroll containers, radio
101
+ // groups, editors that claim Tab — and only the wrap is decided here.
102
+ const focusEdge = (toLast: boolean) => {
103
+ const panel = dialogRef.current
104
+ if (!panel) return
105
+ // A candidate can still refuse focus; stopping on the first refusal would
106
+ // leave focus on the guard, one keypress from the page behind.
107
+ const items = tabbableElements(panel)
108
+ for (const el of toLast ? items.reverse() : items) {
109
+ el.focus()
110
+ if (document.activeElement === el) return
111
+ }
112
+ panel.focus()
113
+ }
114
+
115
+ // Leaving past one edge wraps to the other; arriving from outside the panel
116
+ // enters at the near edge.
117
+ const handleGuardFocus = (edge: 'start' | 'end') => (e: ReactFocusEvent<HTMLSpanElement>) => {
118
+ const panel = dialogRef.current
119
+ const fromInside = e.relatedTarget instanceof Node && !!panel?.contains(e.relatedTarget)
120
+ focusEdge(edge === 'start' ? fromInside : !fromInside)
121
+ }
122
+
123
+ // Focus drops to body when the focused control is disabled or removed (a
124
+ // confirm button entering its loading state); the next Tab would then land
125
+ // behind the modal. Focus elsewhere (a menu portaled out of the panel) is
126
+ // left alone.
127
+ const recoverStrayTab = (e: KeyboardEvent) => {
128
+ const panel = dialogRef.current
129
+ const active = document.activeElement
130
+ if (!panel || (active && active !== document.body)) return
131
+ e.preventDefault()
132
+ focusEdge(e.shiftKey)
133
+ }
134
+
135
+ // relatedTarget on the first focus into the panel is the opener. Reading
136
+ // document.activeElement in an effect would miss it when a child `autoFocus`
137
+ // has already moved focus during commit — which also happens before
138
+ // dialogRef is attached, hence currentTarget.
139
+ const handlePanelFocus = (e: ReactFocusEvent<HTMLDivElement>) => {
140
+ const panel = e.currentTarget
141
+ if (!open || openerRef.current || !panel.contains(e.target as Node)) return
142
+ const from = e.relatedTarget
143
+ openerRef.current = { el: from instanceof HTMLElement && !panel.contains(from) ? from : null }
144
+ }
145
+
47
146
  useEffect(() => {
48
147
  if (!open) return
49
148
 
50
149
  const handleDocumentKeyDown = (e: KeyboardEvent) => {
51
- if (e.key !== 'Escape') return
150
+ if (e.key !== 'Escape' && e.key !== 'Tab') return
151
+ // A dialog still animating out is inert and no longer the one on top.
52
152
  const modalDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'))
153
+ .filter(d => !d.closest('[inert]'))
53
154
  const topDialog = modalDialogs[modalDialogs.length - 1]
54
155
  if (topDialog !== dialogRef.current) return
55
156
 
157
+ if (e.key === 'Tab') {
158
+ recoverStrayTab(e)
159
+ return
160
+ }
161
+
56
162
  if (dialogRef.current?.contains(e.target as Node)) return
57
163
 
58
164
  e.stopPropagation()
@@ -67,17 +173,44 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
67
173
  return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
68
174
  }, [open, closable, onClose])
69
175
 
70
- // Move focus into the dialog for accessibility and tab navigation
176
+ // The panel mounts a render after `open` flips (useAnimatedUnmount sets
177
+ // shouldRender in an effect), so this must also wait on shouldRender.
71
178
  useEffect(() => {
72
- if (open && dialogRef.current) {
73
- dialogRef.current.focus()
179
+ const panel = dialogRef.current
180
+ if (!open || !shouldRender || !panel) return
181
+ if (!panel.contains(document.activeElement)) panel.focus()
182
+ }, [open, shouldRender])
183
+
184
+ // Restore at logical close (open → false, or unmount while open), not after
185
+ // the exit. The microtask lets StrictMode's simulated unmount/remount cancel
186
+ // the restore; otherwise dev builds would pull focus out of an autoFocus child.
187
+ useEffect(() => {
188
+ if (!open) return
189
+ pendingRestoreRef.current = false
190
+ return () => {
191
+ pendingRestoreRef.current = true
192
+ queueMicrotask(() => {
193
+ if (!pendingRestoreRef.current) return
194
+ pendingRestoreRef.current = false
195
+ const opener = openerRef.current?.el
196
+ openerRef.current = null
197
+ if (!opener?.isConnected) return
198
+ const active = document.activeElement
199
+ const panel = dialogRef.current
200
+ // Don't steal focus from wherever the close action deliberately sent it.
201
+ const focusStillOurs = !active || active === document.body || !!panel?.contains(active)
202
+ if (focusStillOurs) opener.focus({ preventScroll: true })
203
+ })
74
204
  }
75
205
  }, [open])
76
206
 
77
207
  if (!shouldRender) return null
78
208
 
79
209
  return createPortal(
80
- <div className="fixed inset-0 z-50 flex items-center justify-center">
210
+ <div
211
+ className={clsx('fixed inset-0 z-50 flex items-center justify-center', !open && 'pointer-events-none')}
212
+ inert={!open}
213
+ >
81
214
  <div
82
215
  className={clsx(
83
216
  'absolute inset-0 bg-black/60 backdrop-blur-sm',
@@ -87,12 +220,16 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
87
220
  style={overlayTransitionStyle(isOpen, 'dialog')}
88
221
  onClick={closable ? onClose : undefined}
89
222
  />
223
+ <span tabIndex={0} aria-hidden data-focus-guard onFocus={handleGuardFocus('start')} className={FOCUS_GUARD} />
90
224
  <div
91
225
  ref={dialogRef}
92
226
  role="dialog"
93
227
  aria-modal="true"
228
+ aria-labelledby={ariaLabelledBy}
229
+ aria-label={ariaLabelledBy ? undefined : ariaLabel}
94
230
  tabIndex={-1}
95
231
  onKeyDown={handleDialogKeyDown}
232
+ onFocus={handlePanelFocus}
96
233
  className={clsx(
97
234
  'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl mx-4 outline-none',
98
235
  TRANSITION_PANEL,
@@ -103,6 +240,7 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
103
240
  >
104
241
  {children}
105
242
  </div>
243
+ <span tabIndex={0} aria-hidden data-focus-guard onFocus={handleGuardFocus('end')} className={FOCUS_GUARD} />
106
244
  </div>,
107
245
  document.body,
108
246
  )
@@ -34,6 +34,8 @@ export interface YamlReviewProps {
34
34
  onClose?: () => void
35
35
  onBack: () => void
36
36
  onApply: () => void
37
+ /** Set when the review replaces a dialog's own title, which names the dialog. */
38
+ titleId?: string
37
39
  }
38
40
 
39
41
  function splitYaml(content: string) {
@@ -82,6 +84,7 @@ export function YamlReview({
82
84
  onClose,
83
85
  onBack,
84
86
  onApply,
87
+ titleId,
85
88
  }: YamlReviewProps) {
86
89
  const [selectedIndex, setSelectedIndex] = useState(0)
87
90
  const reviewRef = useRef<HTMLDivElement>(null)
@@ -177,7 +180,7 @@ export function YamlReview({
177
180
  </button>
178
181
  <div className="h-4 w-px bg-theme-border" />
179
182
  <div>
180
- <div className="text-sm font-semibold text-theme-text-primary">
183
+ <div id={titleId} className="text-sm font-semibold text-theme-text-primary">
181
184
  Review cluster-admitted changes
182
185
  </div>
183
186
  <div className="text-xs text-theme-text-tertiary">
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
- import { ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection } from './drawer-components'
3
+ import { EventsSection, ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection } from './drawer-components'
4
4
 
5
5
  const problems = [
6
6
  { color: 'red' as const, message: 'Application is Degraded' },
@@ -49,3 +49,16 @@ describe('RelatedResourcesSection', () => {
49
49
  expect(html).toContain('<button')
50
50
  })
51
51
  })
52
+
53
+ describe('Recent Events layout', () => {
54
+ const events = Array.from({ length: 24 }, (_, i) => ({ id: String(i), source: 'k8s_event', eventType: 'Normal', reason: `Reason${i}`, timestamp: '2026-09-27T00:00:00Z' })) as any
55
+ it('keeps drawer scrolling but lets fullscreen events expand in page flow', () => {
56
+ const drawer = renderToString(<EventsSection events={events} />)
57
+ const fullscreen = renderToString(<EventsSection events={events} fullscreen />)
58
+ expect(drawer).toContain('max-h-64')
59
+ expect(drawer).not.toContain('Show 16 more events')
60
+ expect(fullscreen).not.toContain('max-h-64')
61
+ expect(fullscreen).toContain('Show 16 more events')
62
+ expect(fullscreen).toContain('Reason23')
63
+ })
64
+ })
@@ -1,3 +1,4 @@
1
+ import { Disclosure } from './Disclosure'
1
2
  import { createContext, useContext, useState } from 'react'
2
3
  import { Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
3
4
  import { clsx } from 'clsx'
@@ -83,25 +84,26 @@ interface SectionProps {
83
84
  children: React.ReactNode
84
85
  defaultExpanded?: boolean
85
86
  contentClassName?: string
87
+ variant?: 'plain' | 'card'
86
88
  }
87
89
 
88
- export function Section({ title, icon: Icon, children, defaultExpanded = true, contentClassName }: SectionProps) {
90
+ export function Section({ title, icon: Icon, children, defaultExpanded = true, contentClassName, variant = 'plain' }: SectionProps) {
89
91
  const [expanded, setExpanded] = useState(defaultExpanded)
90
92
  const { panelId, buttonProps } = useDisclosure(expanded)
91
93
 
92
94
  return (
93
- <div className="border-b-subtle pb-4 last:border-0">
95
+ <div className={variant === 'card' ? 'min-w-0 overflow-hidden rounded-lg border border-theme-border bg-theme-surface' : 'border-b-subtle pb-4 last:border-0'}>
94
96
  <button
95
97
  {...buttonProps}
96
98
  onClick={() => setExpanded(!expanded)}
97
- className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
99
+ className={clsx('flex items-center gap-2 w-full text-left hover:text-theme-text-primary transition-colors', variant === 'card' ? clsx('border-theme-border bg-theme-elevated px-3 py-2.5', expanded && 'border-b') : 'mb-2')}
98
100
  >
99
101
  <CollapseChevron open={expanded} className="w-4 h-4" />
100
102
  {Icon && <Icon className="w-4 h-4 text-theme-text-secondary" />}
101
- <span className="text-sm font-medium text-theme-text-secondary">{title}</span>
103
+ <span className={clsx("font-medium text-theme-text-secondary", variant === 'card' ? 'text-xs uppercase tracking-wider' : 'text-sm')}>{title}</span>
102
104
  </button>
103
105
  <Collapse open={expanded} id={panelId}>
104
- <div className={contentClassName ?? 'pl-6'}>{children}</div>
106
+ <div className={contentClassName ?? (variant === 'card' ? 'p-3' : 'pl-6')}>{children}</div>
105
107
  </Collapse>
106
108
  </div>
107
109
  )
@@ -293,8 +295,10 @@ export function ConditionsSection({
293
295
  conditions,
294
296
  getConditionTone,
295
297
  defaultExpanded,
298
+ variant,
296
299
  }: {
297
300
  conditions?: any[]
301
+ variant?: 'plain' | 'card'
298
302
  defaultExpanded?: boolean
299
303
  getConditionTone?: (condition: any) => ConditionTone | undefined
300
304
  }) {
@@ -313,6 +317,7 @@ export function ConditionsSection({
313
317
 
314
318
  return (
315
319
  <Section
320
+ variant={variant}
316
321
  title={`Conditions (${conditions.length})${failCount > 0 ? ` · ${failCount} failing` : ''}`}
317
322
  defaultExpanded={defaultExpanded ?? conditions.length <= 6}
318
323
  >
@@ -889,7 +894,6 @@ const RELATIONSHIP_TRUNCATE_LIMIT = 10
889
894
  export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps) {
890
895
  const [showAll, setShowAll] = useState(false)
891
896
  if (!refs || refs.length === 0) return null
892
-
893
897
  const truncated = !showAll && refs.length > RELATIONSHIP_TRUNCATE_LIMIT
894
898
  const visibleRefs = truncated ? refs.slice(0, RELATIONSHIP_TRUNCATE_LIMIT) : refs
895
899
 
@@ -1027,10 +1031,12 @@ interface EventsSectionProps {
1027
1031
  updatesError?: Error | null
1028
1032
  /** Optional hint shown below the event list (e.g. "See Timeline tab for related resources") */
1029
1033
  hint?: React.ReactNode
1034
+ fullscreen?: boolean
1030
1035
  }
1031
1036
 
1032
- export function EventsSection({ events, updates = [], isLoading, eventsError, updatesError, hint }: EventsSectionProps) {
1037
+ export function EventsSection({ events, updates = [], isLoading, eventsError, updatesError, hint, fullscreen = false }: EventsSectionProps) {
1033
1038
  const [showUpdates, setShowUpdates] = useState(false)
1039
+ const [showAllEvents, setShowAllEvents] = useState(false)
1034
1040
 
1035
1041
  if (isLoading) {
1036
1042
  return (
@@ -1085,42 +1091,46 @@ export function EventsSection({ events, updates = [], isLoading, eventsError, up
1085
1091
  )
1086
1092
  }
1087
1093
 
1094
+ const renderEvent = (event: TimelineEvent, i: number) => (
1095
+ <div
1096
+ key={`${event.id}-${i}`}
1097
+ className={clsx(
1098
+ 'p-2 rounded text-sm border-l-2',
1099
+ event.eventType === 'Warning' || (isChangeEvent(event) && event.eventType === 'delete')
1100
+ ? 'bg-red-500/10 border-red-500'
1101
+ : isK8sEvent(event)
1102
+ ? 'bg-blue-500/10 border-blue-500'
1103
+ : 'bg-theme-elevated/30 border-theme-border'
1104
+ )}
1105
+ >
1106
+ <div className="flex items-center justify-between gap-2">
1107
+ <span className="font-medium text-theme-text-primary">
1108
+ {isK8sEvent(event) ? event.reason : event.eventType}
1109
+ </span>
1110
+ <span className="text-xs text-theme-text-tertiary">
1111
+ {formatEventTime(event.timestamp)}
1112
+ </span>
1113
+ </div>
1114
+ {event.message && (
1115
+ <div className="text-xs text-theme-text-secondary mt-1 line-clamp-2">
1116
+ {event.message}
1117
+ </div>
1118
+ )}
1119
+ {isChangeEvent(event) && event.diff?.summary && (
1120
+ <div className="text-xs text-theme-text-secondary mt-1">
1121
+ {event.diff.summary}
1122
+ </div>
1123
+ )}
1124
+ </div>
1125
+ )
1088
1126
  return (
1089
1127
  <Section title={`Recent Events (${visible.length})`} defaultExpanded>
1090
- <div className="space-y-2 max-h-64 overflow-y-auto">
1091
- {visible.map((event, i) => (
1092
- <div
1093
- key={`${event.id}-${i}`}
1094
- className={clsx(
1095
- 'p-2 rounded text-sm border-l-2',
1096
- event.eventType === 'Warning' || (isChangeEvent(event) && event.eventType === 'delete')
1097
- ? 'bg-red-500/10 border-red-500'
1098
- : isK8sEvent(event)
1099
- ? 'bg-blue-500/10 border-blue-500'
1100
- : 'bg-theme-elevated/30 border-theme-border'
1101
- )}
1102
- >
1103
- <div className="flex items-center justify-between gap-2">
1104
- <span className="font-medium text-theme-text-primary">
1105
- {isK8sEvent(event) ? event.reason : event.eventType}
1106
- </span>
1107
- <span className="text-xs text-theme-text-tertiary">
1108
- {formatEventTime(event.timestamp)}
1109
- </span>
1110
- </div>
1111
- {event.message && (
1112
- <div className="text-xs text-theme-text-secondary mt-1 line-clamp-2">
1113
- {event.message}
1114
- </div>
1115
- )}
1116
- {isChangeEvent(event) && event.diff?.summary && (
1117
- <div className="text-xs text-theme-text-secondary mt-1">
1118
- {event.diff.summary}
1119
- </div>
1120
- )}
1121
- </div>
1122
- ))}
1128
+ <div tabIndex={fullscreen ? undefined : 0} className={clsx('space-y-2', !fullscreen && 'max-h-64 overflow-y-auto')}>
1129
+ {(fullscreen ? visible.slice(0, 8) : visible).map(renderEvent)}
1123
1130
  </div>
1131
+ {fullscreen && visible.length > 8 && <Disclosure open={showAllEvents} onOpenChange={setShowAllEvents} summary={showAllEvents ? 'Show fewer events' : `Show ${visible.length - 8} more events`} className="mt-2" summaryClassName="text-xs text-accent-text">
1132
+ <div className="mt-2 space-y-2">{visible.slice(8).map(renderEvent)}</div>
1133
+ </Disclosure>}
1124
1134
  {errors}
1125
1135
  {toggle && <div className="mt-2">{toggle}</div>}
1126
1136
  {hint && <div className="mt-2">{hint}</div>}