@flamingo-stack/openframe-frontend-core 0.0.202 → 0.0.203

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 (105) hide show
  1. package/dist/{chunk-OII2IERE.cjs → chunk-25LVV26X.cjs} +4 -4
  2. package/dist/chunk-25LVV26X.cjs.map +1 -0
  3. package/dist/{chunk-JIKTMXTZ.cjs → chunk-3YH2M76N.cjs} +896 -740
  4. package/dist/chunk-3YH2M76N.cjs.map +1 -0
  5. package/dist/{chunk-55HF462A.js → chunk-CPXLQ57U.js} +6 -7
  6. package/dist/chunk-CPXLQ57U.js.map +1 -0
  7. package/dist/{chunk-IDULPYOU.js → chunk-E6Q6UGDK.js} +1897 -1741
  8. package/dist/chunk-E6Q6UGDK.js.map +1 -0
  9. package/dist/{chunk-3B43AHYE.cjs → chunk-RMB5DVED.cjs} +6 -7
  10. package/dist/chunk-RMB5DVED.cjs.map +1 -0
  11. package/dist/{chunk-4ML3NA2L.js → chunk-XGL5FKIK.js} +4 -4
  12. package/dist/chunk-XGL5FKIK.js.map +1 -0
  13. package/dist/components/chat/chat-ticket-item.d.ts.map +1 -1
  14. package/dist/components/features/index.cjs +4 -4
  15. package/dist/components/features/index.js +3 -3
  16. package/dist/components/features/select-button.d.ts.map +1 -1
  17. package/dist/components/index.cjs +6 -4
  18. package/dist/components/index.cjs.map +1 -1
  19. package/dist/components/index.js +5 -3
  20. package/dist/components/navigation/index.cjs +4 -4
  21. package/dist/components/navigation/index.js +3 -3
  22. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  23. package/dist/components/resizable.d.ts +1 -1
  24. package/dist/components/ui/button/split-button.d.ts.map +1 -1
  25. package/dist/components/ui/data-table/data-table-row.d.ts +16 -4
  26. package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
  27. package/dist/components/ui/file-manager/index.cjs +52 -52
  28. package/dist/components/ui/file-manager/index.cjs.map +1 -1
  29. package/dist/components/ui/file-manager/index.js +3 -3
  30. package/dist/components/ui/file-manager/index.js.map +1 -1
  31. package/dist/components/ui/floating-tooltip.d.ts +3 -1
  32. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  33. package/dist/components/ui/index.cjs +6 -4
  34. package/dist/components/ui/index.cjs.map +1 -1
  35. package/dist/components/ui/index.d.ts +1 -0
  36. package/dist/components/ui/index.d.ts.map +1 -1
  37. package/dist/components/ui/index.js +5 -3
  38. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  39. package/dist/components/ui/radio-group.d.ts.map +1 -1
  40. package/dist/components/ui/ticket-info-section.d.ts.map +1 -1
  41. package/dist/components/ui/ticket-note-card.d.ts.map +1 -1
  42. package/dist/components/ui/truncate-text.d.ts +33 -0
  43. package/dist/components/ui/truncate-text.d.ts.map +1 -0
  44. package/dist/components/user-summary-stub.d.ts.map +1 -1
  45. package/dist/hooks/index.cjs +2 -2
  46. package/dist/hooks/index.js +1 -1
  47. package/dist/index.cjs +6 -4
  48. package/dist/index.cjs.map +1 -1
  49. package/dist/index.js +5 -3
  50. package/package.json +1 -1
  51. package/src/components/chat/chat-container.tsx +2 -2
  52. package/src/components/chat/chat-ticket-item.tsx +2 -3
  53. package/src/components/features/board/ticket-card.tsx +2 -2
  54. package/src/components/features/filters-dropdown.tsx +1 -1
  55. package/src/components/features/notifications/notification-tile.tsx +2 -2
  56. package/src/components/features/policy-configuration-panel.tsx +1 -1
  57. package/src/components/features/push-button-selector.tsx +1 -1
  58. package/src/components/features/select-button.tsx +2 -3
  59. package/src/components/features/video-bites-display.tsx +1 -1
  60. package/src/components/features/waitlist-form.tsx +1 -1
  61. package/src/components/filter-chip.tsx +1 -1
  62. package/src/components/layout/title-block.tsx +2 -2
  63. package/src/components/navigation/header-organization-filter.tsx +1 -1
  64. package/src/components/navigation/navigation-sidebar.tsx +107 -54
  65. package/src/components/platform/ScriptInfoSection.tsx +1 -1
  66. package/src/components/shared/onboarding/onboarding-step-card.tsx +2 -2
  67. package/src/components/shared/product-release/product-release-card.tsx +6 -6
  68. package/src/components/shared/product-release/release-detail-page.tsx +1 -1
  69. package/src/components/ui/assignee-dropdown.tsx +3 -3
  70. package/src/components/ui/autocomplete.tsx +2 -2
  71. package/src/components/ui/button/split-button.tsx +3 -5
  72. package/src/components/ui/checkbox-block.tsx +1 -1
  73. package/src/components/ui/data-table/data-table-row.tsx +82 -48
  74. package/src/components/ui/device-card-compact.tsx +2 -2
  75. package/src/components/ui/device-card.tsx +2 -2
  76. package/src/components/ui/entity-image.tsx +1 -1
  77. package/src/components/ui/field-wrapper.tsx +1 -1
  78. package/src/components/ui/file-manager/file-manager-table-row.tsx +2 -2
  79. package/src/components/ui/file-upload.tsx +2 -2
  80. package/src/components/ui/filter-list.tsx +1 -1
  81. package/src/components/ui/floating-tooltip.tsx +9 -5
  82. package/src/components/ui/hidden-tags-popup.tsx +1 -1
  83. package/src/components/ui/index.ts +1 -0
  84. package/src/components/ui/info-card.tsx +2 -2
  85. package/src/components/ui/input-trigger.tsx +1 -2
  86. package/src/components/ui/organization-card.tsx +3 -3
  87. package/src/components/ui/radio-group.tsx +2 -3
  88. package/src/components/ui/search-input.tsx +2 -2
  89. package/src/components/ui/service-card.tsx +3 -3
  90. package/src/components/ui/tag.tsx +1 -1
  91. package/src/components/ui/tags-manager.tsx +2 -2
  92. package/src/components/ui/ticket-attachments-list.tsx +1 -1
  93. package/src/components/ui/ticket-info-section.tsx +2 -3
  94. package/src/components/ui/ticket-note-card.tsx +4 -1
  95. package/src/components/ui/toaster.tsx +3 -3
  96. package/src/components/ui/truncate-text.tsx +116 -0
  97. package/src/components/user-summary-stub.tsx +32 -26
  98. package/src/components/vendor-display-button.tsx +1 -1
  99. package/src/stories/SplitButton.stories.tsx +7 -1
  100. package/dist/chunk-3B43AHYE.cjs.map +0 -1
  101. package/dist/chunk-4ML3NA2L.js.map +0 -1
  102. package/dist/chunk-55HF462A.js.map +0 -1
  103. package/dist/chunk-IDULPYOU.js.map +0 -1
  104. package/dist/chunk-JIKTMXTZ.cjs.map +0 -1
  105. package/dist/chunk-OII2IERE.cjs.map +0 -1
@@ -1,8 +1,8 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
4
4
  import { useLocalStorage } from '../../hooks/ui/use-local-storage'
5
- import { useLgUp } from '../../hooks/ui/use-media-query'
5
+ import { useLgUp, useMdUp } from '../../hooks/ui/use-media-query'
6
6
  import { NavigationSidebarConfig, NavigationSidebarItem } from '../../types/navigation'
7
7
  import { cn } from '../../utils'
8
8
  import { NavigationSidebarHeader } from './navigation-sidebar-header'
@@ -23,25 +23,57 @@ export interface NavigationSidebarProps {
23
23
  }
24
24
 
25
25
  export function NavigationSidebar({ config, disabled = false }: NavigationSidebarProps) {
26
+ const isMdUp = useMdUp() ?? false
26
27
  const isLgUp = useLgUp() ?? false
27
28
 
28
- // useLocalStorage reads from localStorage first, then falls back to this value
29
- const [minimized, setMinimized] = useLocalStorage<boolean>(
29
+ // Tablet = md viewport but not lg. On tablet the sidebar floats over the
30
+ // content area (overlay) instead of pushing it like on desktop.
31
+ const isTablet = isMdUp && !isLgUp
32
+
33
+ // Desktop preference persists across sessions. Tablet state is in-memory
34
+ // only so entering tablet always starts minimized without clobbering the
35
+ // user's desktop choice.
36
+ const [desktopMinimized, setDesktopMinimized] = useLocalStorage<boolean>(
30
37
  STORAGE_KEY,
31
- !isLgUp || (config.minimized ?? false)
38
+ config.minimized ?? false,
32
39
  )
40
+ const [tabletMinimized, setTabletMinimized] = useState(true)
41
+
42
+ useEffect(() => {
43
+ if (isTablet) setTabletMinimized(true)
44
+ }, [isTablet])
45
+
46
+ const minimized = isTablet ? tabletMinimized : desktopMinimized
33
47
 
34
48
  // Enable transitions only after the correct width is painted
35
49
  const [transitionsEnabled, setTransitionsEnabled] = useState(false)
36
50
 
37
- // On tablet (md but not lg), force minimized layout regardless of stored value
38
- const isMinimized = !isLgUp || minimized
39
- const showLabel = isLgUp && !minimized
51
+ const isOverlayOpen = isTablet && !minimized
52
+
53
+ const showLabel = !minimized
40
54
 
41
55
  const handleToggle = useCallback(() => {
42
- setMinimized(prev => !prev)
56
+ if (isTablet) {
57
+ setTabletMinimized(prev => !prev)
58
+ } else {
59
+ setDesktopMinimized(prev => !prev)
60
+ }
43
61
  config.onToggleMinimized?.()
44
- }, [setMinimized, config])
62
+ }, [isTablet, setDesktopMinimized, config])
63
+
64
+ const closeOverlay = useCallback(() => {
65
+ setTabletMinimized(true)
66
+ }, [])
67
+
68
+ // Dismiss the tablet overlay with Escape so it behaves like a transient panel
69
+ useEffect(() => {
70
+ if (!isOverlayOpen) return
71
+ const handleKeyDown = (e: KeyboardEvent) => {
72
+ if (e.key === 'Escape') closeOverlay()
73
+ }
74
+ document.addEventListener('keydown', handleKeyDown)
75
+ return () => document.removeEventListener('keydown', handleKeyDown)
76
+ }, [isOverlayOpen, closeOverlay])
45
77
 
46
78
  const handleItemClick = useCallback((item: NavigationSidebarItem, event?: React.MouseEvent) => {
47
79
  event?.stopPropagation()
@@ -58,14 +90,12 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
58
90
  secondaryItems: config.items.filter(item => item.section === 'secondary'),
59
91
  }), [config.items])
60
92
 
61
- const sidebarWidth = useMemo(() => {
62
- if (isLgUp === undefined) {
63
- return `${MINIMIZED_WIDTH}px`
64
- }
65
- return isMinimized ? `${MINIMIZED_WIDTH}px` : `${EXPANDED_WIDTH}px`
66
- }, [isLgUp, isMinimized])
93
+ const sidebarWidth = useMemo(
94
+ () => (minimized ? `${MINIMIZED_WIDTH}px` : `${EXPANDED_WIDTH}px`),
95
+ [minimized],
96
+ )
67
97
 
68
- const isHydrated = isLgUp !== undefined
98
+ const isHydrated = isMdUp !== undefined && isLgUp !== undefined
69
99
 
70
100
  useLayoutEffect(() => {
71
101
  if (isHydrated && !transitionsEnabled) {
@@ -77,36 +107,47 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
77
107
  }, [isHydrated, transitionsEnabled])
78
108
 
79
109
  return (
80
- <aside
81
- className={cn(
82
- "flex-col h-full hidden md:flex",
83
- "bg-ods-card border-r border-ods-border",
84
- transitionsEnabled && "transition-[width] duration-300",
85
- config.className,
110
+ <>
111
+ {/* Backdrop scrim — only visible on tablet while the overlay is open */}
112
+ <div
113
+ className={cn(
114
+ "fixed inset-0 z-[40] bg-black/50",
115
+ "hidden md:block lg:hidden",
116
+ "transition-opacity duration-300",
117
+ isOverlayOpen ? "opacity-100" : "opacity-0 pointer-events-none",
118
+ )}
119
+ onClick={closeOverlay}
120
+ aria-hidden="true"
121
+ />
122
+
123
+ {/* Flex-flow placeholder — reserves the collapsed 56px slot on tablet so
124
+ the main content keeps its position while the sidebar floats above it */}
125
+ {isTablet && (
126
+ <div
127
+ className="h-full hidden md:block flex-shrink-0"
128
+ style={{ width: `${MINIMIZED_WIDTH}px` }}
129
+ aria-hidden="true"
130
+ />
86
131
  )}
87
- style={{ width: sidebarWidth }}
88
- aria-label="Main navigation sidebar"
89
- >
90
- {isHydrated && (
91
- <>
92
- <NavigationSidebarHeader minimized={isMinimized} />
93
-
94
- <div className="flex-1 flex flex-col justify-between py-4">
95
- <nav className="flex flex-col" aria-label="Primary navigation">
96
- {primaryItems.map(item => (
97
- <NavigationSidebarItemButton
98
- key={item.id}
99
- item={item}
100
- showLabel={showLabel}
101
- disabled={disabled}
102
- onClick={handleItemClick}
103
- />
104
- ))}
105
- </nav>
106
-
107
- {secondaryItems.length > 0 && (
108
- <nav className="flex flex-col" aria-label="Secondary navigation">
109
- {secondaryItems.map(item => (
132
+
133
+ <aside
134
+ className={cn(
135
+ "flex-col hidden md:flex flex-shrink-0",
136
+ "bg-ods-card border-r border-ods-border",
137
+ isTablet ? "fixed top-0 left-0 h-screen z-[45]" : "relative h-full",
138
+ transitionsEnabled && "transition-[width] duration-300",
139
+ config.className,
140
+ )}
141
+ style={{ width: sidebarWidth }}
142
+ aria-label="Main navigation sidebar"
143
+ >
144
+ {isHydrated && (
145
+ <>
146
+ <NavigationSidebarHeader minimized={minimized} />
147
+
148
+ <div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
149
+ <nav className="flex flex-col" aria-label="Primary navigation">
150
+ {primaryItems.map(item => (
110
151
  <NavigationSidebarItemButton
111
152
  key={item.id}
112
153
  item={item}
@@ -116,18 +157,30 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
116
157
  />
117
158
  ))}
118
159
  </nav>
119
- )}
120
- </div>
121
160
 
122
- {isLgUp && (
161
+ {secondaryItems.length > 0 && (
162
+ <nav className="flex flex-col" aria-label="Secondary navigation">
163
+ {secondaryItems.map(item => (
164
+ <NavigationSidebarItemButton
165
+ key={item.id}
166
+ item={item}
167
+ showLabel={showLabel}
168
+ disabled={disabled}
169
+ onClick={handleItemClick}
170
+ />
171
+ ))}
172
+ </nav>
173
+ )}
174
+ </div>
175
+
123
176
  <NavigationSidebarToggle
124
- minimized={isMinimized}
177
+ minimized={minimized}
125
178
  showLabel={showLabel}
126
179
  onToggle={handleToggle}
127
180
  />
128
- )}
129
- </>
130
- )}
131
- </aside>
181
+ </>
182
+ )}
183
+ </aside>
184
+ </>
132
185
  )
133
186
  }
@@ -100,7 +100,7 @@ function InfoCell({ label, value, avatar, className }: InfoCellProps) {
100
100
 
101
101
  {/* Text content */}
102
102
  <div className="flex flex-col min-w-0">
103
- <span className="text-ods-text-primary font-['DM_Sans'] text-[14px] leading-[20px] md:text-[18px] md:leading-[24px] font-medium truncate">
103
+ <span className="text-ods-text-primary font-['DM_Sans'] text-[14px] leading-[20px] md:text-[18px] md:leading-[24px] font-medium truncate" title={value}>
104
104
  {value}
105
105
  </span>
106
106
  <span className="text-ods-text-secondary font-['DM_Sans'] text-[12px] leading-[16px] md:text-[14px] md:leading-[20px] font-medium truncate">
@@ -66,10 +66,10 @@ export function OnboardingStepCard({
66
66
  >
67
67
  {/* Left column - content */}
68
68
  <div className="flex-1 w-full md:w-auto min-w-0 flex flex-col justify-center gap-1">
69
- <h3 className="text-h4 text-ods-text-primary truncate">
69
+ <h3 className="text-h4 text-ods-text-primary truncate" title={step.title}>
70
70
  {step.title}
71
71
  </h3>
72
- <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary truncate h-[20px]">
72
+ <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary truncate h-[20px]" title={step.description}>
73
73
  {step.description}
74
74
  </p>
75
75
  </div>
@@ -463,7 +463,7 @@ export function ProductReleaseCard({
463
463
  as the loaded text — zero load-to-resolve baseline shift. */}
464
464
  <span className="flex min-w-0 flex-1 flex-col gap-0.5 min-h-14">
465
465
  <span className="flex items-center gap-2 min-w-0 h-5">
466
- <span className="truncate text-sm font-semibold leading-5 text-ods-text-primary min-w-0">
466
+ <span className="truncate text-sm font-semibold leading-5 text-ods-text-primary min-w-0" title={title}>
467
467
  {title}
468
468
  </span>
469
469
  {version ? (
@@ -478,7 +478,7 @@ export function ProductReleaseCard({
478
478
  </span>
479
479
  </span>
480
480
  <span className="flex items-center min-w-0 h-4">
481
- <span className="truncate text-[11px] leading-4 text-ods-text-secondary/80">
481
+ <span className="truncate text-[11px] leading-4 text-ods-text-secondary/80" title={summary || undefined}>
482
482
  {/* The literal between the curly-quote string is U+00A0
483
483
  (NBSP). The hub's `COMPACT_CARD_ROW_FILLER` is also
484
484
  NBSP; ASCII space here would let React collapse the
@@ -545,11 +545,11 @@ export function ProductReleaseCard({
545
545
  {/* Left column - content */}
546
546
  <div className="flex-1 w-full md:w-auto min-w-0 flex flex-col justify-center gap-2">
547
547
  <div className="min-h-[48px] flex items-center">
548
- <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2">
548
+ <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2" title={title}>
549
549
  {title}
550
550
  </h3>
551
551
  </div>
552
- <p className="text-h4 text-ods-text-secondary line-clamp-3">
552
+ <p className="text-h4 text-ods-text-secondary line-clamp-3" title={summary || ' '}>
553
553
  {summary || ' '}
554
554
  </p>
555
555
  </div>
@@ -585,11 +585,11 @@ export function ProductReleaseCard({
585
585
  {/* Left column - content */}
586
586
  <div className="flex-1 w-full md:w-auto min-w-0 flex flex-col justify-center gap-2">
587
587
  <div className="min-h-[48px] flex items-center">
588
- <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2">
588
+ <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] line-clamp-2" title={title}>
589
589
  {title}
590
590
  </h3>
591
591
  </div>
592
- <p className="text-h4 text-ods-text-secondary line-clamp-3">
592
+ <p className="text-h4 text-ods-text-secondary line-clamp-3" title={summary || ' '}>
593
593
  {summary || ' '}
594
594
  </p>
595
595
  </div>
@@ -282,7 +282,7 @@ export function ReleaseDetailPage({
282
282
  variant="round"
283
283
  />
284
284
  <div className="flex flex-col gap-0 flex-1 min-w-0">
285
- <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate">
285
+ <p className="text-h3 tracking-[-0.36px] text-ods-text-primary truncate" title={author?.full_name || 'Unknown Author'}>
286
286
  {author?.full_name || 'Unknown Author'}
287
287
  </p>
288
288
  <p className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary">
@@ -159,7 +159,7 @@ function CompactAssigneeDropdown({
159
159
  variant="round"
160
160
  className="h-6 w-6 shrink-0"
161
161
  />
162
- <span className="flex-1 truncate text-h4 text-ods-text-primary">{opt.label}</span>
162
+ <span className="flex-1 truncate text-h4 text-ods-text-primary" title={opt.label}>{opt.label}</span>
163
163
  {isCurrent && <CheckIcon className="size-4 shrink-0 text-ods-accent" />}
164
164
  </button>
165
165
  )
@@ -194,7 +194,7 @@ function DefaultAssigneeDropdown({
194
194
  variant="round"
195
195
  className="h-6 w-6 shrink-0"
196
196
  />
197
- <span className="truncate">{opt.label}</span>
197
+ <span className="truncate" title={opt.label}>{opt.label}</span>
198
198
  </div>
199
199
  )
200
200
  }, [])
@@ -253,7 +253,7 @@ function DefaultAssigneeDropdown({
253
253
  className="flex items-center gap-[var(--spacing-system-xxs)] cursor-pointer group text-left"
254
254
  >
255
255
  <PenEditIcon className="size-4 shrink-0 text-ods-text-secondary group-hover:text-ods-accent transition-colors" />
256
- <span className="text-h4 text-ods-text-primary truncate">{currentAssignee!.name}</span>
256
+ <span className="text-h4 text-ods-text-primary truncate" title={currentAssignee!.name}>{currentAssignee!.name}</span>
257
257
  </button>
258
258
  </div>
259
259
  <span className="text-h6 text-ods-text-secondary truncate">Assigned</span>
@@ -574,7 +574,7 @@ function AutocompleteInner<T = string>(
574
574
  )}
575
575
  onClick={handleCreate}
576
576
  >
577
- <span className="truncate">+ Create &quot;{inputValue.trim()}&quot;</span>
577
+ <span className="truncate" title={`+ Create "${inputValue.trim()}"`}>+ Create &quot;{inputValue.trim()}&quot;</span>
578
578
  </div>
579
579
  ) : (
580
580
  <div className="px-3 py-2 text-ods-text-secondary text-[14px]">
@@ -607,7 +607,7 @@ function AutocompleteInner<T = string>(
607
607
  >
608
608
  {renderOption ? renderOption(option, isSelected) : (
609
609
  <div className="flex items-center justify-between w-full min-w-0">
610
- <span className="truncate">{option.label}</span>
610
+ <span className="truncate" title={option.label}>{option.label}</span>
611
611
  {isSelected && (
612
612
  <CheckIcon className="text-ods-accent" size={20} />
613
613
  )}
@@ -16,7 +16,7 @@ const splitHalfBase = [
16
16
  "whitespace-nowrap transition-colors duration-200",
17
17
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-focus focus-visible:z-10",
18
18
  "disabled:pointer-events-none",
19
- "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:h-5 [&_svg]:w-5",
19
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0",
20
20
  ]
21
21
 
22
22
  const splitHalfVariants = cva(splitHalfBase, {
@@ -28,8 +28,8 @@ const splitHalfVariants = cva(splitHalfBase, {
28
28
  destructive: buttonSurfaceClasses.destructive,
29
29
  },
30
30
  size: {
31
- default: "h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3",
32
- small: "h-6 md:h-8 px-[var(--spacing-system-xs)] text-h5",
31
+ default: "h-10 md:h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 [&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6",
32
+ small: "h-6 md:h-8 px-[var(--spacing-system-xs)] text-h5 [&_svg]:h-3 [&_svg]:w-3 md:[&_svg]:h-4 md:[&_svg]:w-4",
33
33
  },
34
34
  side: { main: "", icon: "" },
35
35
  },
@@ -55,8 +55,6 @@ const splitHalfVariants = cva(splitHalfBase, {
55
55
  // Icon half: per Figma, narrower than main height (default: 40×48; small: 32×32).
56
56
  { side: "icon", size: "default", class: "w-10 px-0" },
57
57
  { side: "icon", size: "small", class: "w-6 md:w-8 px-0" },
58
-
59
- { size: "small", class: "[&_svg]:h-4 [&_svg]:w-4" },
60
58
  ],
61
59
  defaultVariants: { variant: "accent", size: "default", side: "main" },
62
60
  })
@@ -80,7 +80,7 @@ const CheckboxBlock = React.forwardRef<
80
80
  </div>
81
81
  </label>
82
82
  {error && (
83
- <p className="absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate text-ods-error">
83
+ <p className="absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate text-ods-error" title={error}>
84
84
  {error}
85
85
  </p>
86
86
  )}
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import Link from 'next/link'
4
- import React, { memo, useCallback, type ReactNode } from 'react'
4
+ import React, { memo, useCallback, useRef, type ReactNode } from 'react'
5
5
  import { flexRender, type Row } from '@tanstack/react-table'
6
6
  import { cn } from '../../../utils/cn'
7
7
  import { ROW_HEIGHT_DESKTOP } from './data-table-skeleton'
@@ -19,16 +19,28 @@ export interface DataTableRowProps<T> {
19
19
  /**
20
20
  * Click-bubbling protocol: any element inside a cell that should NOT trigger
21
21
  * `onRowClick` / row navigation must carry the `data-no-row-click` attribute.
22
- * The row checks `target.closest('[data-no-row-click]')` before firing
23
- * `onClick(item)`. This is the single primitive that interactive cells
24
- * (action buttons, dropdown menus, checkboxes) must opt into.
22
+ * The row checks `target.closest('[data-no-row-click]')` and short-circuits:
23
+ * in `onClick` mode it skips the consumer's handler; in link mode (when
24
+ * `href` is set) it calls `e.preventDefault()` so `<Link>` does not navigate.
25
+ *
26
+ * Clicks originating from portaled descendants (e.g. `FloatingTooltip`,
27
+ * dropdown menus rendered through `FloatingPortal`) bubble through React's
28
+ * component tree and reach this handler, but their DOM target lives outside
29
+ * the row subtree. The handler ignores any click whose target is not
30
+ * physically contained within the row element — no `stopPropagation`
31
+ * required at the source.
32
+ *
33
+ * In link mode the row IS the `<Link>` — content lives inside it, not under
34
+ * an absolute overlay — so native browser link behaviour works: hover,
35
+ * right-click "Open in new tab", middle-click, `Cmd+click`, focus outlines,
36
+ * `:visited` styles, etc.
25
37
  *
26
38
  * Example column with action buttons:
27
39
  * ```tsx
28
40
  * {
29
41
  * id: 'actions',
30
42
  * cell: ({ row }) => (
31
- * <div data-no-row-click className="flex gap-2 justify-end pointer-events-auto">
43
+ * <div data-no-row-click className="flex gap-2 justify-end">
32
44
  * <Button onClick={() => edit(row.original)}>Edit</Button>
33
45
  * </div>
34
46
  * ),
@@ -49,61 +61,83 @@ function DataTableRowImpl<T>({
49
61
  className,
50
62
  }: DataTableRowProps<T>) {
51
63
  const isLinkMode = Boolean(href) && !onClick
64
+ const containerRef = useRef<HTMLElement | null>(null)
52
65
 
53
66
  const handleClick = useCallback(
54
67
  (e: React.MouseEvent) => {
55
68
  const target = e.target as HTMLElement
56
- if (target.closest('[data-no-row-click]')) return
69
+ // React-bubbled events from portaled descendants (tooltips, dropdowns, etc.)
70
+ // reach this handler even though their DOM target lives outside the row.
71
+ // Suppress them — and in link mode, preventDefault so `<Link>` does not navigate.
72
+ if (!containerRef.current?.contains(target)) {
73
+ if (isLinkMode) e.preventDefault()
74
+ return
75
+ }
76
+ if (target.closest('[data-no-row-click]')) {
77
+ if (isLinkMode) e.preventDefault()
78
+ return
79
+ }
57
80
  onClick?.(row.original)
58
81
  },
59
- [onClick, row.original],
82
+ [onClick, row.original, isLinkMode],
60
83
  )
61
84
 
62
- return (
85
+ const containerClassName = cn(
86
+ 'block rounded-md bg-ods-card border border-ods-border overflow-hidden no-underline text-inherit',
87
+ (onClick || isLinkMode) && 'cursor-pointer hover:bg-ods-bg-active transition-colors',
88
+ className,
89
+ )
90
+
91
+ const cells = (
63
92
  <div
64
93
  className={cn(
65
- 'relative rounded-md bg-ods-card border border-ods-border overflow-hidden',
66
- (onClick || isLinkMode) &&
67
- 'cursor-pointer hover:bg-ods-bg-active transition-colors',
68
- className,
94
+ 'flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)]',
95
+ compact ? 'py-[var(--spacing-system-xsf)]' : `py-0 ${ROW_HEIGHT_DESKTOP}`,
69
96
  )}
70
- onClick={isLinkMode ? undefined : handleClick}
71
97
  >
72
- {isLinkMode && href && (
73
- <Link
74
- href={href}
75
- prefetch={false}
76
- className="absolute inset-0"
77
- aria-label="View details"
78
- />
79
- )}
80
- <div
81
- className={cn(
82
- 'relative flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)]',
83
- compact ? 'py-[var(--spacing-system-xsf)]' : `py-0 ${ROW_HEIGHT_DESKTOP}`,
84
- isLinkMode && 'pointer-events-none',
85
- )}
98
+ {row.getVisibleCells().map(cell => {
99
+ const meta = cell.column.columnDef.meta
100
+ return (
101
+ <div
102
+ key={cell.id}
103
+ className={cn(
104
+ 'flex flex-col overflow-hidden',
105
+ alignJustify(meta?.align),
106
+ meta?.width || 'flex-1 min-w-0',
107
+ meta?.cellClassName,
108
+ getHideClasses(meta?.hideAt),
109
+ )}
110
+ >
111
+ <CellContent>
112
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
113
+ </CellContent>
114
+ </div>
115
+ )
116
+ })}
117
+ </div>
118
+ )
119
+
120
+ if (isLinkMode && href) {
121
+ return (
122
+ <Link
123
+ href={href}
124
+ prefetch={false}
125
+ ref={containerRef as React.RefObject<HTMLAnchorElement>}
126
+ className={containerClassName}
127
+ onClick={handleClick}
86
128
  >
87
- {row.getVisibleCells().map(cell => {
88
- const meta = cell.column.columnDef.meta
89
- return (
90
- <div
91
- key={cell.id}
92
- className={cn(
93
- 'flex flex-col overflow-hidden',
94
- alignJustify(meta?.align),
95
- meta?.width || 'flex-1 min-w-0',
96
- meta?.cellClassName,
97
- getHideClasses(meta?.hideAt),
98
- )}
99
- >
100
- <CellContent>
101
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
102
- </CellContent>
103
- </div>
104
- )
105
- })}
106
- </div>
129
+ {cells}
130
+ </Link>
131
+ )
132
+ }
133
+
134
+ return (
135
+ <div
136
+ ref={containerRef as React.RefObject<HTMLDivElement>}
137
+ className={containerClassName}
138
+ onClick={onClick ? handleClick : undefined}
139
+ >
140
+ {cells}
107
141
  </div>
108
142
  )
109
143
  }
@@ -114,7 +148,7 @@ export const DataTableRow = memo(DataTableRowImpl) as typeof DataTableRowImpl
114
148
  function CellContent({ children }: { children: ReactNode }) {
115
149
  if (typeof children === 'string' || typeof children === 'number') {
116
150
  return (
117
- <span className="text-h4 text-ods-text-primary truncate">{children}</span>
151
+ <span className="text-h4 text-ods-text-primary truncate" title={String(children)}>{children}</span>
118
152
  )
119
153
  }
120
154
  return <>{children}</>
@@ -35,12 +35,12 @@ export function DeviceCardCompact({
35
35
  {...props}
36
36
  >
37
37
  {hasName && (
38
- <span className="font-['DM_Sans'] font-medium text-[16px] leading-[20px] text-ods-text-primary truncate">
38
+ <span className="font-['DM_Sans'] font-medium text-[16px] leading-[20px] text-ods-text-primary truncate" title={deviceName}>
39
39
  {deviceName}
40
40
  </span>
41
41
  )}
42
42
  {hasOrg && (
43
- <span className="font-['DM_Sans'] font-medium text-[14px] leading-[18px] text-ods-text-secondary truncate">
43
+ <span className="font-['DM_Sans'] font-medium text-[14px] leading-[18px] text-ods-text-secondary truncate" title={organization}>
44
44
  {organization}
45
45
  </span>
46
46
  )}
@@ -103,12 +103,12 @@ export function DeviceCard({
103
103
  className="shrink-0"
104
104
  />
105
105
  )}
106
- <span className="text-h4 text-ods-text-primary truncate">
106
+ <span className="text-h4 text-ods-text-primary truncate" title={device.name}>
107
107
  {device.name}
108
108
  </span>
109
109
  </div>
110
110
  {device.organization && (
111
- <span className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary truncate">
111
+ <span className="font-['DM_Sans'] font-medium text-[14px] leading-[20px] text-ods-text-secondary truncate" title={device.organization}>
112
112
  {device.organization}
113
113
  </span>
114
114
  )}
@@ -48,7 +48,7 @@ export function EntityImage({ src, alt, fallbackText, className }: EntityImagePr
48
48
  alt={alt ?? ''}
49
49
  onError={() => setImageFailed(true)}
50
50
  className={cn(
51
- 'size-[52px] md:size-[60px] shrink-0 rounded-md border border-ods-border object-cover',
51
+ 'size-[52px] md:size-[60px] shrink-0 rounded-md border border-ods-border object-contain',
52
52
  className,
53
53
  )}
54
54
  />
@@ -33,7 +33,7 @@ const FieldWrapper = React.forwardRef<HTMLDivElement, FieldWrapperProps>(
33
33
  )}
34
34
  {children}
35
35
  {error && (
36
- <p className={cn("absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate", errorVariantClasses[errorVariant])}>
36
+ <p className={cn("absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate", errorVariantClasses[errorVariant])} title={error}>
37
37
  {error}
38
38
  </p>
39
39
  )}
@@ -76,11 +76,11 @@ export function FileManagerTableRow({
76
76
  size="md"
77
77
  />
78
78
  <div className="flex flex-col min-w-0">
79
- <span className="text-sm text-ods-text-primary truncate">
79
+ <span className="text-sm text-ods-text-primary truncate" title={file.name}>
80
80
  {file.name}
81
81
  </span>
82
82
  {showPath && file.path && (
83
- <span className="text-xs text-ods-text-secondary truncate">
83
+ <span className="text-xs text-ods-text-secondary truncate" title={file.path}>
84
84
  {file.path}
85
85
  </span>
86
86
  )}
@@ -347,7 +347,7 @@ export function FileUpload({
347
347
  )}
348
348
  >
349
349
  <div className="flex-1 min-w-0">
350
- <p className="text-heading-5 font-medium text-ods-text-primary truncate">
350
+ <p className="text-heading-5 font-medium text-ods-text-primary truncate" title={entry.fileName}>
351
351
  {entry.fileName}
352
352
  </p>
353
353
  <div className="flex items-center gap-2">
@@ -385,7 +385,7 @@ export function FileUpload({
385
385
  className="flex items-center gap-3 p-3 rounded-[6px] bg-ods-card border border-ods-border"
386
386
  >
387
387
  <div className="flex-1 min-w-0">
388
- <p className="text-heading-5 font-medium text-ods-text-primary truncate">
388
+ <p className="text-heading-5 font-medium text-ods-text-primary truncate" title={file.name}>
389
389
  {file.name}
390
390
  </p>
391
391
  <p className="text-heading-6 text-ods-text-secondary">