@flamingo-stack/openframe-frontend-core 0.0.486 → 0.0.487

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 (98) hide show
  1. package/dist/{chunk-PHYEC3EG.cjs → chunk-4TFUB2CR.cjs} +26 -26
  2. package/dist/{chunk-PHYEC3EG.cjs.map → chunk-4TFUB2CR.cjs.map} +1 -1
  3. package/dist/{chunk-ZVFH4LTD.cjs → chunk-6SBAF5VG.cjs} +5 -5
  4. package/dist/{chunk-ZVFH4LTD.cjs.map → chunk-6SBAF5VG.cjs.map} +1 -1
  5. package/dist/{chunk-5WZGZ6B2.js → chunk-AUGM74CO.js} +4 -4
  6. package/dist/{chunk-REURWDBC.js → chunk-BC74IKAX.js} +4705 -4482
  7. package/dist/chunk-BC74IKAX.js.map +1 -0
  8. package/dist/{chunk-Z3V42W7P.js → chunk-BTMYUK4P.js} +2 -2
  9. package/dist/{chunk-HQITFULS.cjs → chunk-EO7MUGY4.cjs} +9 -9
  10. package/dist/{chunk-HQITFULS.cjs.map → chunk-EO7MUGY4.cjs.map} +1 -1
  11. package/dist/{chunk-ZTRRB2Y2.js → chunk-G4XU7BYU.js} +2 -2
  12. package/dist/{chunk-J5KX5K5T.js → chunk-GXOUXAXU.js} +2 -2
  13. package/dist/{chunk-F3BH5B7M.cjs → chunk-HWK5CH5N.cjs} +11 -11
  14. package/dist/{chunk-F3BH5B7M.cjs.map → chunk-HWK5CH5N.cjs.map} +1 -1
  15. package/dist/{chunk-KRKNTK5Y.js → chunk-ITEPRN6L.js} +2 -2
  16. package/dist/{chunk-RILRBUDL.cjs → chunk-K6MITU6M.cjs} +62 -62
  17. package/dist/{chunk-RILRBUDL.cjs.map → chunk-K6MITU6M.cjs.map} +1 -1
  18. package/dist/{chunk-C4QKMOQK.cjs → chunk-KTD2MGYF.cjs} +7 -7
  19. package/dist/{chunk-C4QKMOQK.cjs.map → chunk-KTD2MGYF.cjs.map} +1 -1
  20. package/dist/{chunk-OW33LYO2.js → chunk-NETEA5E4.js} +4 -4
  21. package/dist/{chunk-6QJ2QRQE.js → chunk-QBDHNUQX.js} +4 -4
  22. package/dist/{chunk-RB6HX4KC.cjs → chunk-QL7S3CH4.cjs} +26 -26
  23. package/dist/{chunk-RB6HX4KC.cjs.map → chunk-QL7S3CH4.cjs.map} +1 -1
  24. package/dist/{chunk-XJ6VL3AP.cjs → chunk-QW6SPNPT.cjs} +37 -37
  25. package/dist/{chunk-XJ6VL3AP.cjs.map → chunk-QW6SPNPT.cjs.map} +1 -1
  26. package/dist/{chunk-NTHTORJ3.cjs → chunk-RAYDQGJV.cjs} +59 -59
  27. package/dist/{chunk-NTHTORJ3.cjs.map → chunk-RAYDQGJV.cjs.map} +1 -1
  28. package/dist/{chunk-KA4ILVCX.js → chunk-SDNMOAT4.js} +2 -2
  29. package/dist/{chunk-XOIKQW2H.js → chunk-SMD67GJP.js} +2 -2
  30. package/dist/{chunk-NZYUSFMI.cjs → chunk-TJSJPOPL.cjs} +573 -350
  31. package/dist/chunk-TJSJPOPL.cjs.map +1 -0
  32. package/dist/{chunk-MD5E5BKO.js → chunk-UBK5HIMH.js} +4 -4
  33. package/dist/{chunk-BVHMDHQY.cjs → chunk-UU4DW7JX.cjs} +31 -31
  34. package/dist/{chunk-BVHMDHQY.cjs.map → chunk-UU4DW7JX.cjs.map} +1 -1
  35. package/dist/{chunk-RR6XPEEG.cjs → chunk-W6GDTB4R.cjs} +15 -15
  36. package/dist/{chunk-RR6XPEEG.cjs.map → chunk-W6GDTB4R.cjs.map} +1 -1
  37. package/dist/{chunk-2BMYCH3D.js → chunk-WLRBZXFB.js} +2 -2
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/contact/index.cjs +3 -3
  43. package/dist/components/contact/index.js +2 -2
  44. package/dist/components/docs/index.cjs +5 -5
  45. package/dist/components/docs/index.js +4 -4
  46. package/dist/components/embeds/index.cjs +3 -3
  47. package/dist/components/embeds/index.js +2 -2
  48. package/dist/components/faq/index.cjs +3 -3
  49. package/dist/components/faq/index.js +2 -2
  50. package/dist/components/features/index.cjs +2 -2
  51. package/dist/components/features/index.js +1 -1
  52. package/dist/components/help-center-pages/index.cjs +20 -20
  53. package/dist/components/help-center-pages/index.js +11 -11
  54. package/dist/components/index.cjs +144 -130
  55. package/dist/components/index.cjs.map +1 -1
  56. package/dist/components/index.js +23 -9
  57. package/dist/components/index.js.map +1 -1
  58. package/dist/components/navigation/index.cjs +2 -2
  59. package/dist/components/navigation/index.js +1 -1
  60. package/dist/components/onboarding-guides/index.cjs +5 -5
  61. package/dist/components/onboarding-guides/index.js +4 -4
  62. package/dist/components/related-content/index.cjs +3 -3
  63. package/dist/components/related-content/index.js +2 -2
  64. package/dist/components/tickets/index.cjs +5 -5
  65. package/dist/components/tickets/index.js +4 -4
  66. package/dist/components/ui/assignee-dropdown.d.ts.map +1 -1
  67. package/dist/components/ui/index.cjs +16 -2
  68. package/dist/components/ui/index.cjs.map +1 -1
  69. package/dist/components/ui/index.d.ts +2 -0
  70. package/dist/components/ui/index.d.ts.map +1 -1
  71. package/dist/components/ui/index.js +15 -1
  72. package/dist/components/ui/searchable-select.d.ts +43 -0
  73. package/dist/components/ui/searchable-select.d.ts.map +1 -0
  74. package/dist/components/ui/test-run.d.ts +74 -0
  75. package/dist/components/ui/test-run.d.ts.map +1 -0
  76. package/dist/index.cjs +16 -2
  77. package/dist/index.cjs.map +1 -1
  78. package/dist/index.js +15 -1
  79. package/package.json +1 -1
  80. package/src/components/ui/assignee-dropdown.tsx +36 -91
  81. package/src/components/ui/index.ts +2 -0
  82. package/src/components/ui/searchable-select.tsx +186 -0
  83. package/src/components/ui/test-run.tsx +282 -0
  84. package/src/stories/SearchableSelect.stories.tsx +139 -0
  85. package/src/stories/TestRun.stories.tsx +118 -0
  86. package/dist/chunk-NZYUSFMI.cjs.map +0 -1
  87. package/dist/chunk-REURWDBC.js.map +0 -1
  88. /package/dist/{chunk-5WZGZ6B2.js.map → chunk-AUGM74CO.js.map} +0 -0
  89. /package/dist/{chunk-Z3V42W7P.js.map → chunk-BTMYUK4P.js.map} +0 -0
  90. /package/dist/{chunk-ZTRRB2Y2.js.map → chunk-G4XU7BYU.js.map} +0 -0
  91. /package/dist/{chunk-J5KX5K5T.js.map → chunk-GXOUXAXU.js.map} +0 -0
  92. /package/dist/{chunk-KRKNTK5Y.js.map → chunk-ITEPRN6L.js.map} +0 -0
  93. /package/dist/{chunk-OW33LYO2.js.map → chunk-NETEA5E4.js.map} +0 -0
  94. /package/dist/{chunk-6QJ2QRQE.js.map → chunk-QBDHNUQX.js.map} +0 -0
  95. /package/dist/{chunk-KA4ILVCX.js.map → chunk-SDNMOAT4.js.map} +0 -0
  96. /package/dist/{chunk-XOIKQW2H.js.map → chunk-SMD67GJP.js.map} +0 -0
  97. /package/dist/{chunk-MD5E5BKO.js.map → chunk-UBK5HIMH.js.map} +0 -0
  98. /package/dist/{chunk-2BMYCH3D.js.map → chunk-WLRBZXFB.js.map} +0 -0
package/dist/index.js CHANGED
@@ -504,6 +504,7 @@ import {
504
504
  ScrollFadeOverlay,
505
505
  ScrollShadow,
506
506
  SearchInput,
507
+ SearchableSelect,
507
508
  SectionSelector,
508
509
  Select,
509
510
  SelectButton,
@@ -586,6 +587,9 @@ import {
586
587
  TagsSelector,
587
588
  TaskTypeIcon,
588
589
  TermsAgreementLabel,
590
+ TestResultsSkeleton,
591
+ TestResultsTable,
592
+ TestRunResults,
589
593
  Textarea,
590
594
  ThemeProvider,
591
595
  ThinkingDisplay,
@@ -604,6 +608,7 @@ import {
604
608
  TimeTrackerHeaderButton,
605
609
  TimeTrackerPanel,
606
610
  TimeTrackerProvider,
611
+ TimingStat,
607
612
  TitleBlock,
608
613
  TitleContentBlock,
609
614
  Toggle,
@@ -749,6 +754,7 @@ import {
749
754
  formatRelativeTime,
750
755
  formatReleaseDate,
751
756
  formatSingularLookupInvocation,
757
+ formatTestRunDuration,
752
758
  formatTicketFullTimestamp,
753
759
  formatTicketRelativeTime,
754
760
  formatTimeWithTimezone,
@@ -965,6 +971,7 @@ import {
965
971
  useSlashCommandRegistry,
966
972
  useSlashCommands,
967
973
  useSseChatAdapter,
974
+ useTestRunState,
968
975
  useTheme,
969
976
  useThemeToggle,
970
977
  useTimeTracker,
@@ -980,7 +987,7 @@ import {
980
987
  walkthroughDismissCookieName,
981
988
  withFirstTouchAttribution,
982
989
  writeDismissCookie
983
- } from "./chunk-REURWDBC.js";
990
+ } from "./chunk-BC74IKAX.js";
984
991
  import {
985
992
  CommandApprovalToast,
986
993
  DEFAULT_MIN_FILL_MS,
@@ -1956,6 +1963,7 @@ export {
1956
1963
  ScrollShadow,
1957
1964
  SearchIcon,
1958
1965
  SearchInput,
1966
+ SearchableSelect,
1959
1967
  SectionSelector,
1960
1968
  Select,
1961
1969
  SelectButton,
@@ -2064,6 +2072,9 @@ export {
2064
2072
  TaskTypeIcon,
2065
2073
  TelegramIcon,
2066
2074
  TermsAgreementLabel,
2075
+ TestResultsSkeleton,
2076
+ TestResultsTable,
2077
+ TestRunResults,
2067
2078
  Textarea,
2068
2079
  ThemeProvider,
2069
2080
  ThinkingDisplay,
@@ -2084,6 +2095,7 @@ export {
2084
2095
  TimeTrackerHeaderButton,
2085
2096
  TimeTrackerPanel,
2086
2097
  TimeTrackerProvider,
2098
+ TimingStat,
2087
2099
  TitleBlock,
2088
2100
  TitleContentBlock,
2089
2101
  ToastCard,
@@ -2267,6 +2279,7 @@ export {
2267
2279
  formatRelativeTime,
2268
2280
  formatReleaseDate,
2269
2281
  formatSingularLookupInvocation,
2282
+ formatTestRunDuration,
2270
2283
  formatTicketFullTimestamp,
2271
2284
  formatTicketRelativeTime,
2272
2285
  formatTimeWithTimezone,
@@ -2570,6 +2583,7 @@ export {
2570
2583
  useSseChatAdapter,
2571
2584
  useSuppressCloneFocus,
2572
2585
  useTablePagination,
2586
+ useTestRunState,
2573
2587
  useTheme,
2574
2588
  useThemeToggle,
2575
2589
  useThrottle,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.486",
3
+ "version": "0.0.487",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,11 +1,10 @@
1
1
  'use client'
2
2
 
3
- import * as PopoverPrimitive from '@radix-ui/react-popover'
4
3
  import * as React from 'react'
5
- import { CheckIcon, PenEditIcon, SearchIcon, UserIcon, UserPlusIcon } from '../icons-v2-generated'
4
+ import { PenEditIcon, UserIcon, UserPlusIcon } from '../icons-v2-generated'
6
5
  import { cn } from '../../utils/cn'
7
6
  import { Autocomplete, type AutocompleteOption } from './autocomplete'
8
- import { Input } from './input'
7
+ import { SearchableSelect, type SearchableSelectOption } from './searchable-select'
9
8
  import { SquareAvatar } from './square-avatar'
10
9
 
11
10
  export interface TicketAssigneeOption {
@@ -42,31 +41,33 @@ function CompactAssigneeDropdown({
42
41
  onAssign,
43
42
  className,
44
43
  }: AssigneeDropdownProps) {
45
- const [isOpen, setIsOpen] = React.useState(false)
46
- const [search, setSearch] = React.useState('')
47
44
  const hasAssignee = !!currentAssignee
48
45
 
49
- React.useEffect(() => {
50
- if (!isOpen) setSearch('')
51
- }, [isOpen])
52
-
53
- const filtered = React.useMemo(() => {
54
- const q = search.trim().toLowerCase()
55
- if (!q) return options
56
- return options.filter(o => o.label.toLowerCase().includes(q))
57
- }, [options, search])
58
-
59
- const orderedOptions = React.useMemo(() => {
60
- if (!currentAssignee) return filtered
61
- const current = filtered.find(o => o.value === currentAssignee.id)
62
- if (!current) return filtered
63
- return [current, ...filtered.filter(o => o.value !== currentAssignee.id)]
64
- }, [filtered, currentAssignee])
46
+ // Current assignee first; SearchableSelect filters but never reorders.
47
+ const orderedOptions = React.useMemo<SearchableSelectOption[]>(() => {
48
+ const withAvatars = options.map(o => ({
49
+ value: o.value,
50
+ label: o.label,
51
+ icon: (
52
+ <SquareAvatar
53
+ src={o.imageUrl}
54
+ alt={o.label}
55
+ fallback={o.label}
56
+ size="sm"
57
+ variant="round"
58
+ className="h-6 w-6 shrink-0"
59
+ />
60
+ ),
61
+ }))
62
+ if (!currentAssignee) return withAvatars
63
+ const current = withAvatars.find(o => o.value === currentAssignee.id)
64
+ if (!current) return withAvatars
65
+ return [current, ...withAvatars.filter(o => o.value !== currentAssignee.id)]
66
+ }, [options, currentAssignee])
65
67
 
66
68
  const handleSelect = (userId: string) => {
67
- const next = currentAssignee?.id === userId ? null : userId
68
- onAssign(next)
69
- setIsOpen(false)
69
+ // Selecting the current assignee again unassigns.
70
+ onAssign(currentAssignee?.id === userId ? null : userId)
70
71
  }
71
72
 
72
73
  const trigger = hasAssignee ? (
@@ -102,73 +103,17 @@ function CompactAssigneeDropdown({
102
103
  )
103
104
 
104
105
  return (
105
- <PopoverPrimitive.Root open={isOpen} onOpenChange={setIsOpen} modal={false}>
106
- <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
107
- <PopoverPrimitive.Portal>
108
- <PopoverPrimitive.Content
109
- align="end"
110
- sideOffset={6}
111
- className={cn(
112
- 'z-50 w-72 bg-ods-card border border-ods-border rounded-[6px] shadow-lg overflow-hidden',
113
- 'data-[state=open]:animate-in data-[state=closed]:animate-out',
114
- 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
115
- 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
116
- 'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
117
- )}
118
- >
119
- <div className="border-b border-ods-border">
120
- <Input
121
- autoFocus
122
- value={search}
123
- onChange={e => setSearch(e.target.value)}
124
- placeholder="Search users..."
125
- startAdornment={<SearchIcon className="size-4 text-ods-text-secondary" />}
126
- className="rounded-none border-0"
127
- />
128
- </div>
129
- <div className="max-h-80 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
130
- {isLoading ? (
131
- <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-h5 text-ods-text-secondary">
132
- Loading…
133
- </div>
134
- ) : orderedOptions.length === 0 ? (
135
- <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-h5 text-ods-text-secondary">
136
- No users found
137
- </div>
138
- ) : (
139
- orderedOptions.map(opt => {
140
- const isCurrent = currentAssignee?.id === opt.value
141
- return (
142
- <button
143
- key={opt.value}
144
- type="button"
145
- role="option"
146
- aria-selected={isCurrent}
147
- onClick={() => handleSelect(opt.value)}
148
- className={cn(
149
- 'flex items-center gap-[var(--spacing-system-xs)] w-full px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-left',
150
- 'hover:bg-ods-bg-hover transition-colors',
151
- isCurrent && 'bg-ods-bg-hover',
152
- )}
153
- >
154
- <SquareAvatar
155
- src={opt.imageUrl}
156
- alt={opt.label}
157
- fallback={opt.label}
158
- size="sm"
159
- variant="round"
160
- className="h-6 w-6 shrink-0"
161
- />
162
- <span className="flex-1 truncate text-h4 text-ods-text-primary" title={opt.label}>{opt.label}</span>
163
- {isCurrent && <CheckIcon className="size-4 shrink-0 text-ods-accent" />}
164
- </button>
165
- )
166
- })
167
- )}
168
- </div>
169
- </PopoverPrimitive.Content>
170
- </PopoverPrimitive.Portal>
171
- </PopoverPrimitive.Root>
106
+ <SearchableSelect
107
+ options={orderedOptions}
108
+ value={currentAssignee?.id ?? null}
109
+ onValueChange={handleSelect}
110
+ searchPlaceholder="Search users..."
111
+ emptyText="No users found"
112
+ isLoading={isLoading}
113
+ trigger={trigger}
114
+ align="end"
115
+ contentClassName="w-72"
116
+ />
172
117
  )
173
118
  }
174
119
 
@@ -167,6 +167,8 @@ export * from './ticket-status-tag'
167
167
  export * from './ticket-status-config-row'
168
168
  export * from './color-swatch'
169
169
  export * from './color-preset-select'
170
+ export * from './searchable-select'
171
+ export * from './test-run'
170
172
  export * from './assignee-dropdown'
171
173
  export * from './ticket-info-section'
172
174
  export * from './ticket-detail-section'
@@ -0,0 +1,186 @@
1
+ 'use client'
2
+
3
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
4
+ import * as React from 'react'
5
+ import { CheckIcon, SearchIcon } from '../icons-v2-generated'
6
+ import { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon'
7
+ import { cn } from '../../utils/cn'
8
+ import { Input } from './input'
9
+
10
+ export interface SearchableSelectOption {
11
+ value: string
12
+ label: string
13
+ /** Optional leading visual for the option row (avatar, icon, badge). */
14
+ icon?: React.ReactNode
15
+ }
16
+
17
+ export interface SearchableSelectProps {
18
+ options: SearchableSelectOption[]
19
+ /** Currently selected option value; null/undefined when nothing is selected. */
20
+ value?: string | null
21
+ onValueChange: (value: string) => void
22
+ /** Default trigger text while nothing is selected. */
23
+ placeholder?: string
24
+ /** Placeholder of the search input rendered as the first dropdown item. */
25
+ searchPlaceholder?: string
26
+ emptyText?: string
27
+ loadingText?: string
28
+ isLoading?: boolean
29
+ disabled?: boolean
30
+ /**
31
+ * Custom trigger node (rendered via Popover.Trigger asChild). When omitted,
32
+ * a select-like button (Input/SelectTrigger styling, chevron, placeholder)
33
+ * is rendered.
34
+ */
35
+ trigger?: React.ReactNode
36
+ /** Custom option-row renderer; default is icon + label + selected check. */
37
+ renderOption?: (option: SearchableSelectOption, isSelected: boolean) => React.ReactNode
38
+ align?: 'start' | 'center' | 'end'
39
+ /** Classes for the default trigger button. */
40
+ className?: string
41
+ /** Classes for the popover content (e.g. a fixed width for icon triggers). */
42
+ contentClassName?: string
43
+ }
44
+
45
+ /**
46
+ * Select-style dropdown whose first item is a search field filtering the
47
+ * option list — the pattern used by the ticket assignee picker ("Search
48
+ * users...") generalized for any entity (devices, users, ...). Provide a
49
+ * custom `trigger` for icon-button use cases; the default trigger looks and
50
+ * behaves like `SelectTrigger`.
51
+ */
52
+ export function SearchableSelect({
53
+ options,
54
+ value = null,
55
+ onValueChange,
56
+ placeholder = 'Select...',
57
+ searchPlaceholder = 'Search...',
58
+ emptyText = 'No results found',
59
+ loadingText = 'Loading…',
60
+ isLoading = false,
61
+ disabled = false,
62
+ trigger,
63
+ renderOption,
64
+ align = 'start',
65
+ className,
66
+ contentClassName,
67
+ }: SearchableSelectProps) {
68
+ const [isOpen, setIsOpen] = React.useState(false)
69
+ const [search, setSearch] = React.useState('')
70
+
71
+ React.useEffect(() => {
72
+ if (!isOpen) setSearch('')
73
+ }, [isOpen])
74
+
75
+ const filtered = React.useMemo(() => {
76
+ const q = search.trim().toLowerCase()
77
+ if (!q) return options
78
+ return options.filter(o => o.label.toLowerCase().includes(q))
79
+ }, [options, search])
80
+
81
+ const selectedOption = React.useMemo(
82
+ () => (value != null ? options.find(o => o.value === value) : undefined),
83
+ [options, value],
84
+ )
85
+
86
+ const handleSelect = (optionValue: string) => {
87
+ onValueChange(optionValue)
88
+ setIsOpen(false)
89
+ }
90
+
91
+ const defaultTrigger = (
92
+ <button
93
+ type="button"
94
+ disabled={disabled}
95
+ className={cn(
96
+ // Mirrors SelectTrigger so the closed state is indistinguishable from
97
+ // the regular Select.
98
+ 'flex w-full items-center justify-between gap-2 rounded-md border px-3 h-11 md:h-12 outline-none text-left',
99
+ 'text-h4 bg-ods-card border-ods-border',
100
+ selectedOption ? 'text-ods-text-primary' : 'text-ods-text-secondary',
101
+ 'enabled:hover:bg-ods-bg-hover enabled:hover:border-ods-border-hover enabled:active:bg-ods-bg-active enabled:active:border-ods-border-active',
102
+ 'data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent',
103
+ 'group disabled:!cursor-not-allowed disabled:bg-ods-bg transition-colors duration-200 cursor-pointer',
104
+ className,
105
+ )}
106
+ >
107
+ <span className="line-clamp-1">{selectedOption ? selectedOption.label : placeholder}</span>
108
+ <Chevron02DownIcon
109
+ className="shrink-0 text-ods-text-secondary transition-all duration-200 group-data-[state=open]:rotate-180 group-data-[state=open]:text-ods-accent"
110
+ size={24}
111
+ />
112
+ </button>
113
+ )
114
+
115
+ return (
116
+ <PopoverPrimitive.Root open={isOpen} onOpenChange={disabled ? undefined : setIsOpen} modal={false}>
117
+ <PopoverPrimitive.Trigger asChild>{trigger ?? defaultTrigger}</PopoverPrimitive.Trigger>
118
+ <PopoverPrimitive.Portal>
119
+ <PopoverPrimitive.Content
120
+ align={align}
121
+ sideOffset={6}
122
+ className={cn(
123
+ 'z-50 min-w-[var(--radix-popover-trigger-width)] bg-ods-card border border-ods-border rounded-md shadow-lg overflow-hidden',
124
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
125
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
126
+ 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
127
+ 'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
128
+ contentClassName,
129
+ )}
130
+ >
131
+ <div className="border-b border-ods-border">
132
+ <Input
133
+ autoFocus
134
+ value={search}
135
+ onChange={e => setSearch(e.target.value)}
136
+ placeholder={searchPlaceholder}
137
+ startAdornment={<SearchIcon className="size-4 text-ods-text-secondary" />}
138
+ className="rounded-none border-0"
139
+ />
140
+ </div>
141
+ <div className="max-h-80 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
142
+ {isLoading ? (
143
+ <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-h5 text-ods-text-secondary">
144
+ {loadingText}
145
+ </div>
146
+ ) : filtered.length === 0 ? (
147
+ <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-h5 text-ods-text-secondary">
148
+ {emptyText}
149
+ </div>
150
+ ) : (
151
+ filtered.map(opt => {
152
+ const isSelected = value === opt.value
153
+ return (
154
+ <button
155
+ key={opt.value}
156
+ type="button"
157
+ role="option"
158
+ aria-selected={isSelected}
159
+ onClick={() => handleSelect(opt.value)}
160
+ className={cn(
161
+ 'flex items-center gap-[var(--spacing-system-xs)] w-full px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-left',
162
+ 'hover:bg-ods-bg-hover transition-colors',
163
+ isSelected && 'bg-ods-bg-hover',
164
+ )}
165
+ >
166
+ {renderOption ? (
167
+ renderOption(opt, isSelected)
168
+ ) : (
169
+ <>
170
+ {opt.icon}
171
+ <span className="flex-1 truncate text-h4 text-ods-text-primary" title={opt.label}>
172
+ {opt.label}
173
+ </span>
174
+ {isSelected && <CheckIcon className="size-4 shrink-0 text-ods-accent" />}
175
+ </>
176
+ )}
177
+ </button>
178
+ )
179
+ })
180
+ )}
181
+ </div>
182
+ </PopoverPrimitive.Content>
183
+ </PopoverPrimitive.Portal>
184
+ </PopoverPrimitive.Root>
185
+ )
186
+ }