@flamingo-stack/openframe-frontend-core 0.0.731 → 0.0.733

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 (149) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-RAHRDJBP.cjs → chunk-3HPB4H4C.cjs} +4 -4
  3. package/dist/{chunk-2UNQJVYN.cjs → chunk-3KGICRH7.cjs} +1457 -1187
  4. package/dist/{chunk-A4HNF4MP.cjs → chunk-3UTPZW36.cjs} +89 -89
  5. package/dist/{chunk-GID62YEN.cjs → chunk-AAFW4STC.cjs} +3 -3
  6. package/dist/{chunk-XDBHGWFH.cjs → chunk-ATMBJBOI.cjs} +16 -16
  7. package/dist/{chunk-YDGBRI3Y.cjs → chunk-AV5533DH.cjs} +15 -15
  8. package/dist/{chunk-BFHLGEJK.cjs → chunk-BP6R5WDW.cjs} +63 -60
  9. package/dist/{chunk-CLILWI6O.cjs → chunk-CJVAA7KL.cjs} +32 -32
  10. package/dist/{chunk-YJFN2W7A.cjs → chunk-DUVOQHQI.cjs} +7 -7
  11. package/dist/{chunk-E3N24573.cjs → chunk-DUXLZPMJ.cjs} +9 -9
  12. package/dist/{chunk-TH4N726A.cjs → chunk-E26I4XH6.cjs} +17 -17
  13. package/dist/{chunk-Y4QRK3CB.cjs → chunk-EMG3T55N.cjs} +7 -7
  14. package/dist/{chunk-JK75ZZP3.cjs → chunk-EQORT7NK.cjs} +59 -59
  15. package/dist/chunk-FCGLSWKR.cjs +417 -0
  16. package/dist/{chunk-MANNIJ42.cjs → chunk-FICJS5OA.cjs} +6 -0
  17. package/dist/{chunk-QP6QS3YU.cjs → chunk-FNRMDDVN.cjs} +48 -50
  18. package/dist/{chunk-KLXHCNDC.cjs → chunk-FTTLBMHU.cjs} +3 -3
  19. package/dist/{chunk-JPZ4Y6X3.cjs → chunk-GB3CHAW7.cjs} +93 -93
  20. package/dist/{chunk-7DUXR4HL.cjs → chunk-GVTDVS5U.cjs} +3 -3
  21. package/dist/{chunk-ZV6473Z4.cjs → chunk-HSWHOCZ3.cjs} +20 -20
  22. package/dist/{chunk-53XZ6MJ5.cjs → chunk-IC4FT3A6.cjs} +28 -28
  23. package/dist/{chunk-6V4GEE55.cjs → chunk-J7JKPTGO.cjs} +12 -12
  24. package/dist/{chunk-FKKUQK4C.cjs → chunk-L4ZNR5V7.cjs} +43 -39
  25. package/dist/{chunk-NYY25425.cjs → chunk-MYBTNOS6.cjs} +2 -2
  26. package/dist/chunk-NTJE6SM6.cjs +28 -0
  27. package/dist/{chunk-RBOIGJYF.cjs → chunk-P3R23TOE.cjs} +35 -35
  28. package/dist/chunk-PX7FY655.cjs +114 -0
  29. package/dist/{chunk-YUXRIR45.cjs → chunk-SA7LDEVJ.cjs} +98 -50
  30. package/dist/{chunk-32ENYVFA.cjs → chunk-TIMCHP4X.cjs} +42 -42
  31. package/dist/{chunk-3SWLL4IM.cjs → chunk-U4T5NLZD.cjs} +8 -8
  32. package/dist/{chunk-HI7EFAFQ.cjs → chunk-XDWY6HAW.cjs} +28 -28
  33. package/dist/{chunk-25H2SZH7.cjs → chunk-XY632BCH.cjs} +4 -4
  34. package/dist/{chunk-HY2KTQSL.cjs → chunk-YIXKH2JO.cjs} +236 -236
  35. package/dist/{chunk-4YPV4OI4.cjs → chunk-ZBNA6TCW.cjs} +82 -82
  36. package/dist/{chunk-HLIJ3U2T.cjs → chunk-ZJNYWNCB.cjs} +23 -21
  37. package/dist/{chunk-HEOD765R.cjs → chunk-ZRYKDN2J.cjs} +32 -379
  38. package/dist/{cloud-tenants-SC4FNAC2.cjs → cloud-tenants-3GOGR3EN.cjs} +22 -22
  39. package/dist/components/agent-identity.d.ts +8 -0
  40. package/dist/components/agent-identity.d.ts.map +1 -1
  41. package/dist/components/agent-identity.js +13 -1
  42. package/dist/components/case-studies/index.cjs +35 -35
  43. package/dist/components/chat/embeddable-chat.d.ts +16 -0
  44. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  45. package/dist/components/chat/embeddable-chat.js +5 -2
  46. package/dist/components/chat/index.cjs +16 -16
  47. package/dist/components/chat/utils/icon-library.d.ts.map +1 -1
  48. package/dist/components/chat/utils/icon-library.js +6 -0
  49. package/dist/components/contact/index.cjs +23 -23
  50. package/dist/components/docs/index.cjs +26 -26
  51. package/dist/components/embeds/index.cjs +23 -23
  52. package/dist/components/faq/index.cjs +23 -23
  53. package/dist/components/features/index.cjs +22 -22
  54. package/dist/components/help-center-pages/index.cjs +138 -138
  55. package/dist/components/index.cjs +89 -82
  56. package/dist/components/meeting-scheduler/index.cjs +67 -67
  57. package/dist/components/navigation/index.cjs +25 -25
  58. package/dist/components/onboarding-guides/index.cjs +25 -25
  59. package/dist/components/product-demo/index.cjs +8 -8
  60. package/dist/components/product-demo/screens/remote-desktop-picture.d.ts.map +1 -1
  61. package/dist/components/product-demo/screens/remote-desktop-picture.js +2 -1
  62. package/dist/components/related-content/index.cjs +10 -10
  63. package/dist/components/tickets/index.cjs +26 -26
  64. package/dist/components/ui/device-wallpaper.d.ts +7 -0
  65. package/dist/components/ui/device-wallpaper.d.ts.map +1 -0
  66. package/dist/components/ui/device-wallpaper.js +5 -0
  67. package/dist/components/ui/feature-list.d.ts +9 -4
  68. package/dist/components/ui/feature-list.d.ts.map +1 -1
  69. package/dist/components/ui/feature-list.js +26 -35
  70. package/dist/components/ui/index.cjs +28 -22
  71. package/dist/components/ui/index.d.ts +3 -1
  72. package/dist/components/ui/index.d.ts.map +1 -1
  73. package/dist/components/ui/index.js +3 -1
  74. package/dist/components/ui/notification-stack.d.ts +33 -0
  75. package/dist/components/ui/notification-stack.d.ts.map +1 -0
  76. package/dist/components/ui/notification-stack.js +78 -0
  77. package/dist/components/ui/numbered-step.d.ts +29 -0
  78. package/dist/components/ui/numbered-step.d.ts.map +1 -0
  79. package/dist/components/ui/numbered-step.js +57 -0
  80. package/dist/components/ui/phone-frame.d.ts +14 -9
  81. package/dist/components/ui/phone-frame.d.ts.map +1 -1
  82. package/dist/components/ui/phone-frame.js +50 -6
  83. package/dist/components/ui/phone-glyphs/lock-camera-glyph.d.ts +5 -0
  84. package/dist/components/ui/phone-glyphs/lock-camera-glyph.d.ts.map +1 -0
  85. package/dist/components/ui/phone-glyphs/lock-camera-glyph.js +14 -0
  86. package/dist/components/ui/phone-glyphs/lock-flashlight-glyph.d.ts +5 -0
  87. package/dist/components/ui/phone-glyphs/lock-flashlight-glyph.d.ts.map +1 -0
  88. package/dist/components/ui/phone-glyphs/lock-flashlight-glyph.js +14 -0
  89. package/dist/components/ui/phone-glyphs/status-bar-glyphs.d.ts +5 -0
  90. package/dist/components/ui/phone-glyphs/status-bar-glyphs.d.ts.map +1 -0
  91. package/dist/components/ui/phone-glyphs/status-bar-glyphs.js +68 -0
  92. package/dist/components/ui/searchable-select.d.ts +29 -3
  93. package/dist/components/ui/searchable-select.d.ts.map +1 -1
  94. package/dist/components/ui/searchable-select.js +101 -45
  95. package/dist/components/ui/snap-carousel.d.ts +5 -8
  96. package/dist/components/ui/snap-carousel.d.ts.map +1 -1
  97. package/dist/components/ui/snap-carousel.js +2 -27
  98. package/dist/components/ui/tab-navigation.d.ts +8 -1
  99. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  100. package/dist/components/ui/tab-navigation.js +30 -20
  101. package/dist/{devices-7MHSJNXS.cjs → devices-POZXTP7X.cjs} +23 -23
  102. package/dist/{embeddable-chat-VPIG3F7E.cjs → embeddable-chat-I3Y5GCCR.cjs} +96 -93
  103. package/dist/hooks/index.cjs +9 -9
  104. package/dist/hooks/ui/use-autoplay.d.ts +50 -13
  105. package/dist/hooks/ui/use-autoplay.d.ts.map +1 -1
  106. package/dist/hooks/ui/use-autoplay.js +76 -13
  107. package/dist/hooks/ui/use-in-view.d.ts +8 -0
  108. package/dist/hooks/ui/use-in-view.d.ts.map +1 -1
  109. package/dist/hooks/ui/use-in-view.js +21 -36
  110. package/dist/hooks/use-agent-identities.d.ts +6 -0
  111. package/dist/hooks/use-agent-identities.d.ts.map +1 -1
  112. package/dist/hooks/use-scenario-player.d.ts +26 -9
  113. package/dist/hooks/use-scenario-player.d.ts.map +1 -1
  114. package/dist/hooks/use-scenario-player.js +12 -9
  115. package/dist/index.cjs +58 -51
  116. package/dist/{knowledge-4DREGSOZ.cjs → knowledge-AP34IMXN.cjs} +23 -23
  117. package/dist/{logs-BWXNT2KP.cjs → logs-CEVMIOPW.cjs} +30 -30
  118. package/dist/{policies-MGFUI23M.cjs → policies-AMBMRCGB.cjs} +26 -26
  119. package/dist/{remote-session-O6QGXV2N.cjs → remote-session-Z4YZFG5E.cjs} +10 -9
  120. package/dist/{software-update-7LF7XEYC.cjs → software-update-Z3MWMSHI.cjs} +26 -26
  121. package/dist/{tickets-board-KNXK747O.cjs → tickets-board-JOSYLOS7.cjs} +13 -13
  122. package/package.json +1 -1
  123. package/src/components/agent-identity.tsx +20 -1
  124. package/src/components/chat/embeddable-chat.tsx +29 -2
  125. package/src/components/chat/utils/icon-library.tsx +7 -0
  126. package/src/components/product-demo/screens/remote-desktop-picture.tsx +2 -1
  127. package/src/components/ui/device-wallpaper.ts +7 -0
  128. package/src/components/ui/feature-list.tsx +24 -30
  129. package/src/components/ui/index.ts +3 -1
  130. package/src/components/ui/notification-stack.tsx +133 -0
  131. package/src/components/ui/numbered-step.tsx +81 -0
  132. package/src/components/ui/phone-frame.tsx +83 -19
  133. package/src/components/ui/phone-glyphs/lock-camera-glyph.tsx +14 -0
  134. package/src/components/ui/phone-glyphs/lock-flashlight-glyph.tsx +14 -0
  135. package/src/components/ui/phone-glyphs/status-bar-glyphs.tsx +45 -0
  136. package/src/components/ui/searchable-select.tsx +124 -32
  137. package/src/components/ui/snap-carousel.tsx +6 -38
  138. package/src/components/ui/tab-navigation.tsx +49 -21
  139. package/src/hooks/ui/use-autoplay.ts +144 -27
  140. package/src/hooks/ui/use-in-view.ts +31 -38
  141. package/src/hooks/use-agent-identities.ts +6 -0
  142. package/src/hooks/use-scenario-player.ts +44 -20
  143. package/dist/chunk-2WXLQ5R5.cjs +0 -99
  144. package/dist/chunk-BE2WAMG5.cjs +0 -54
  145. package/dist/chunk-LCTDHLDL.cjs +0 -43
  146. package/dist/components/ui/playback-toggle.d.ts +0 -24
  147. package/dist/components/ui/playback-toggle.d.ts.map +0 -1
  148. package/dist/components/ui/playback-toggle.js +0 -32
  149. package/src/components/ui/playback-toggle.tsx +0 -53
@@ -0,0 +1,45 @@
1
+ // The status bar's signal, Wi-Fi and battery of the drawn phone's lock screen, as the design draws it. It belongs to `PhoneFrame` and
2
+ // nothing else, so it is not part of the icon set. It takes the text colour around it.
3
+
4
+ /** The status bar's right side: signal, Wi-Fi and battery, as one drawing. */
5
+ export function StatusBarGlyphs({ className }: { className?: string }) {
6
+ return (
7
+ <svg viewBox="0 0 54.441 9.78982" fill="none" aria-hidden="true" className={className}>
8
+ <path
9
+ d="M6.89491 2.89491C6.89491 2.51412 7.20361 2.20542 7.5844 2.20542H8.27389C8.65469 2.20542 8.96338 2.51412 8.96338 2.89491V8.41084C8.96338 8.79164 8.65469 9.10033 8.27389 9.10033H7.5844C7.20361 9.10033 6.89491 8.79164 6.89491 8.41084V2.89491Z"
10
+ fill="currentColor"
11
+ />
12
+ <path
13
+ d="M10.3424 1.51593C10.3424 1.13513 10.6511 0.826438 11.0319 0.826438H11.7213C12.1021 0.826438 12.4108 1.13513 12.4108 1.51593V8.41084C12.4108 8.79164 12.1021 9.10033 11.7213 9.10033H11.0319C10.6511 9.10033 10.3424 8.79164 10.3424 8.41084V1.51593Z"
14
+ fill="currentColor"
15
+ />
16
+ <path
17
+ d="M3.44746 5.30813C3.44746 4.92733 3.75615 4.61864 4.13695 4.61864H4.82644C5.20723 4.61864 5.51593 4.92733 5.51593 5.30813V8.41084C5.51593 8.79164 5.20723 9.10033 4.82644 9.10033H4.13695C3.75615 9.10033 3.44746 8.79164 3.44746 8.41084V5.30813Z"
18
+ fill="currentColor"
19
+ />
20
+ <path
21
+ d="M0 7.03186C0 6.65106 0.308696 6.34237 0.689491 6.34237H1.37898C1.75978 6.34237 2.06847 6.65106 2.06847 7.03186V8.41084C2.06847 8.79164 1.75978 9.10033 1.37898 9.10033H0.689491C0.308696 9.10033 0 8.79164 0 8.41084V7.03186Z"
22
+ fill="currentColor"
23
+ />
24
+ <path
25
+ d="M22.1436 6.97595C23.1225 6.13113 24.5563 6.13095 25.5352 6.97595C25.5844 7.02139 25.6129 7.08596 25.6143 7.15368C25.6156 7.22145 25.5894 7.28687 25.542 7.33435L24.0088 8.91345C23.9639 8.95973 23.9027 8.98571 23.8389 8.98571C23.775 8.98566 23.7138 8.9598 23.669 8.91345L22.1358 7.33435C22.0884 7.28683 22.0621 7.22144 22.0635 7.15368C22.0649 7.08593 22.0943 7.02136 22.1436 6.97595ZM20.0703 5.0863C22.1795 3.08361 25.4465 3.08362 27.5557 5.0863C27.6033 5.13326 27.6302 5.19841 27.6309 5.26599C27.6314 5.33341 27.6052 5.39784 27.5586 5.44567L26.6729 6.35974C26.5815 6.45307 26.4335 6.45548 26.3399 6.36462C25.6473 5.72459 24.7467 5.36952 23.8125 5.3695C22.8788 5.3699 21.9783 5.72488 21.2861 6.36462C21.1925 6.45548 21.0445 6.45307 20.9531 6.35974L20.0674 5.44567C20.0209 5.39792 19.9946 5.33335 19.9951 5.26599C19.9958 5.19843 20.0227 5.13325 20.0703 5.0863ZM18.001 3.19958C21.236 0.0354236 26.3393 0.0353572 29.5742 3.19958C29.6211 3.2466 29.6481 3.31125 29.6485 3.37829C29.6488 3.44529 29.6224 3.50946 29.5762 3.557L28.6895 4.47107C28.5981 4.56485 28.4493 4.56633 28.3565 4.474C27.124 3.27797 25.4887 2.61088 23.7881 2.61071C22.0873 2.61071 20.4515 3.27787 19.2188 4.474C19.126 4.56648 18.9771 4.565 18.8858 4.47107L17.9981 3.557C17.952 3.50946 17.9264 3.44515 17.9268 3.37829C17.9272 3.31125 17.9541 3.24656 18.001 3.19958Z"
26
+ fill="currentColor"
27
+ />
28
+ <path
29
+ opacity="0.35"
30
+ d="M37.9222 0.363281H50.0277C51.3498 0.363404 52.4211 1.43566 52.4212 2.75781V6.20508C52.4212 7.5273 51.3499 8.59949 50.0277 8.59961H37.9222C36.6 8.59953 35.5277 7.52732 35.5277 6.20508V2.75781C35.5278 1.43564 36.6 0.363363 37.9222 0.363281Z"
31
+ stroke="currentColor"
32
+ strokeWidth="0.727473"
33
+ />
34
+ <path
35
+ opacity="0.4"
36
+ d="M53.4749 3.44746V6.35735C54.0603 6.1109 54.441 5.53758 54.441 4.9024C54.441 4.26722 54.0603 3.69391 53.4749 3.44746Z"
37
+ fill="currentColor"
38
+ />
39
+ <path
40
+ d="M36.5434 2.75797C36.5434 1.99638 37.1608 1.37898 37.9224 1.37898H50.0274C50.789 1.37898 51.4064 1.99637 51.4064 2.75796V6.20542C51.4064 6.96701 50.789 7.5844 50.0274 7.5844H37.9224C37.1608 7.5844 36.5434 6.96701 36.5434 6.20542V2.75797Z"
41
+ fill="currentColor"
42
+ />
43
+ </svg>
44
+ );
45
+ }
@@ -1,20 +1,28 @@
1
1
  'use client';
2
2
 
3
3
  import * as PopoverPrimitive from '@radix-ui/react-popover';
4
- import { type ReactNode, useMemo, useState } from 'react';
4
+ import { type ReactNode, useId, useMemo, useState } from 'react';
5
5
  import { useKeyboardCollisionPadding } from '../../hooks/ui/use-keyboard-collision-padding';
6
6
  import { cn } from '../../utils/cn';
7
7
  import { CheckIcon, SearchIcon } from '../icons-v2-generated';
8
8
  import { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon';
9
+ import { FieldWrapper } from './field-wrapper';
9
10
  import { Input } from './input';
10
11
 
11
12
  export interface SearchableSelectOption {
12
13
  value: string;
13
14
  label: string;
14
- /** Optional leading visual for the option row (avatar, icon, badge). */
15
+ /** Optional leading visual for the option row (avatar, icon, badge). The
16
+ * default trigger leads with the selected option's icon too. */
15
17
  icon?: ReactNode;
18
+ /** Extra terms the search matches besides `label` — e.g. the stored key of a
19
+ * glyph (`chart-pie`) whose label reads "Pie chart". */
20
+ keywords?: string[];
16
21
  }
17
22
 
23
+ /** How the dropdown lays its options out — see `SearchableSelectProps.layout`. */
24
+ export type SearchableSelectLayout = 'list' | 'grid';
25
+
18
26
  export interface SearchableSelectProps {
19
27
  options: SearchableSelectOption[];
20
28
  /** Currently selected option value; null/undefined when nothing is selected. */
@@ -34,7 +42,27 @@ export interface SearchableSelectProps {
34
42
  * is rendered.
35
43
  */
36
44
  trigger?: ReactNode;
37
- /** Custom option-row renderer; default is icon + label + selected check. */
45
+ /** `id` of the default trigger button, which `label` names through `htmlFor`; generated when omitted. */
46
+ id?: string;
47
+ /**
48
+ * Label above the default trigger — the same `FieldWrapper` treatment
49
+ * `SelectTrigger` gives its `label`, so a searchable field lines up with the
50
+ * plain selects beside it. A custom `trigger` owns its own label.
51
+ */
52
+ label?: string;
53
+ /** Label scale forwarded to FieldWrapper ('large' = text-h4 for designs with body-scale field titles). */
54
+ labelVariant?: 'default' | 'large';
55
+ /** Validation message under the default trigger; also paints its border red. */
56
+ error?: string;
57
+ /**
58
+ * `'list'` (default) stacks full-width rows. `'grid'` tiles the options —
59
+ * icon above a centered caption, `minmax(7rem, 1fr)` columns — the
60
+ * icon-picker pattern. The grid fills the popover, so size it through
61
+ * `contentClassName` (360px fits three tiles).
62
+ */
63
+ layout?: SearchableSelectLayout;
64
+ /** Custom option-row renderer; default is icon + label + selected check
65
+ * (icon above the caption in the grid layout). */
38
66
  renderOption?: (option: SearchableSelectOption, isSelected: boolean) => ReactNode;
39
67
  align?: 'start' | 'center' | 'end';
40
68
  /** Classes for the default trigger button. */
@@ -50,22 +78,37 @@ export interface SearchableSelectProps {
50
78
  * custom `trigger` for icon-button use cases; the default trigger looks and
51
79
  * behaves like `SelectTrigger`.
52
80
  */
53
- export function SearchableSelect({
54
- options,
55
- value = null,
56
- onValueChange,
57
- placeholder = 'Select...',
58
- searchPlaceholder = 'Search...',
59
- emptyText = 'No results found',
60
- loadingText = 'Loading…',
61
- isLoading = false,
62
- disabled = false,
63
- trigger,
64
- renderOption,
65
- align = 'start',
66
- className,
67
- contentClassName,
68
- }: SearchableSelectProps) {
81
+ export function SearchableSelect(allProps: SearchableSelectProps) {
82
+ const {
83
+ options,
84
+ value = null,
85
+ onValueChange,
86
+ placeholder = 'Select...',
87
+ searchPlaceholder = 'Search...',
88
+ emptyText = 'No results found',
89
+ loadingText = 'Loading…',
90
+ isLoading = false,
91
+ disabled = false,
92
+ trigger,
93
+ id,
94
+ label,
95
+ labelVariant,
96
+ error,
97
+ layout = 'list',
98
+ renderOption,
99
+ align = 'start',
100
+ className,
101
+ contentClassName,
102
+ } = allProps;
103
+ // Wired for a message even while `error` is undefined — see `FieldWrapper.errorSlot`.
104
+ const errorSlot = 'error' in allProps;
105
+ const isInvalid = !!error;
106
+ const isGrid = layout === 'grid';
107
+ // The label names the trigger through `htmlFor`: clicking it focuses the
108
+ // button and assistive tech reads the field by it. A caller's `id` wins.
109
+ const generatedId = useId();
110
+ const triggerId = id ?? generatedId;
111
+
69
112
  const [isOpen, setIsOpen] = useState(false);
70
113
  const [search, setSearch] = useState('');
71
114
  const keyboardPadding = useKeyboardCollisionPadding();
@@ -79,7 +122,9 @@ export function SearchableSelect({
79
122
  const filtered = useMemo(() => {
80
123
  const q = search.trim().toLowerCase();
81
124
  if (!q) return options;
82
- return options.filter(o => o.label.toLowerCase().includes(q));
125
+ return options.filter(
126
+ o => o.label.toLowerCase().includes(q) || (o.keywords ?? []).some(k => k.toLowerCase().includes(q)),
127
+ );
83
128
  }, [options, search]);
84
129
 
85
130
  const selectedOption = useMemo(
@@ -94,8 +139,10 @@ export function SearchableSelect({
94
139
 
95
140
  const defaultTrigger = (
96
141
  <button
142
+ id={triggerId}
97
143
  type="button"
98
144
  disabled={disabled}
145
+ data-invalid={isInvalid || undefined}
99
146
  className={cn(
100
147
  // Mirrors SelectTrigger so the closed state is indistinguishable from
101
148
  // the regular Select.
@@ -103,22 +150,45 @@ export function SearchableSelect({
103
150
  'border-ods-border bg-ods-card text-h4',
104
151
  selectedOption ? 'text-ods-text-primary' : 'text-ods-text-secondary',
105
152
  'enabled:hover:border-ods-border-hover enabled:hover:bg-ods-bg-hover enabled:active:border-ods-border-active enabled:active:bg-ods-bg-active',
106
- 'data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent',
153
+ !isInvalid && 'data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent',
107
154
  'group cursor-pointer transition-colors duration-200 disabled:!cursor-not-allowed disabled:bg-ods-bg',
155
+ isInvalid &&
156
+ 'border-ods-error enabled:hover:border-ods-error enabled:active:border-ods-error data-[state=open]:border-ods-error',
108
157
  className,
109
158
  )}
110
159
  >
111
- <span className="line-clamp-1">{selectedOption ? selectedOption.label : placeholder}</span>
160
+ <span className="flex min-w-0 flex-1 items-center gap-2">
161
+ {selectedOption?.icon}
162
+ <span className="truncate">{selectedOption ? selectedOption.label : placeholder}</span>
163
+ </span>
112
164
  <Chevron02DownIcon
113
- className="shrink-0 text-ods-text-secondary transition-all duration-200 group-data-[state=open]:rotate-180 group-data-[state=open]:text-ods-accent"
165
+ className={cn(
166
+ 'shrink-0 text-ods-text-secondary transition-all duration-200 group-data-[state=open]:rotate-180',
167
+ isInvalid ? 'text-ods-error' : 'group-data-[state=open]:text-ods-accent',
168
+ )}
114
169
  size={24}
115
170
  />
116
171
  </button>
117
172
  );
118
173
 
174
+ const triggerElement = <PopoverPrimitive.Trigger asChild>{trigger ?? defaultTrigger}</PopoverPrimitive.Trigger>;
175
+
176
+ // A `role="listbox"` child that is a status line, not an option: in the grid
177
+ // it has to span the row or it would sit in one tile-sized cell.
178
+ const statusClass = cn(
179
+ 'px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h5',
180
+ isGrid && 'col-span-full',
181
+ );
182
+
119
183
  return (
120
184
  <PopoverPrimitive.Root open={isOpen} onOpenChange={disabled ? undefined : setIsOpen} modal={false}>
121
- <PopoverPrimitive.Trigger asChild>{trigger ?? defaultTrigger}</PopoverPrimitive.Trigger>
185
+ {!trigger && (label !== undefined || errorSlot) ? (
186
+ <FieldWrapper label={label} htmlFor={triggerId} labelVariant={labelVariant} error={error} errorSlot={errorSlot}>
187
+ {triggerElement}
188
+ </FieldWrapper>
189
+ ) : (
190
+ triggerElement
191
+ )}
122
192
  <PopoverPrimitive.Portal>
123
193
  <PopoverPrimitive.Content
124
194
  align={align}
@@ -147,15 +217,22 @@ export function SearchableSelect({
147
217
  className="rounded-none border-0"
148
218
  />
149
219
  </div>
150
- <div className="max-h-80 min-h-0 overflow-y-auto py-[var(--spacing-system-xs)]" role="listbox">
220
+ <div
221
+ className={cn(
222
+ 'max-h-80 min-h-0 overflow-y-auto',
223
+ // Tiles grow from 7rem: the popover's own border and a classic
224
+ // always-on scrollbar both eat into the track, so 7.5rem (the
225
+ // Figma cell) dropped a 360px popover to two columns. The column
226
+ // count follows the popover width. Each tile draws its own
227
+ // bottom/right rule, so the grid needs no gap.
228
+ isGrid ? 'grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))]' : 'py-[var(--spacing-system-xs)]',
229
+ )}
230
+ role="listbox"
231
+ >
151
232
  {isLoading ? (
152
- <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h5">
153
- {loadingText}
154
- </div>
233
+ <div className={statusClass}>{loadingText}</div>
155
234
  ) : filtered.length === 0 ? (
156
- <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-s)] text-ods-text-secondary text-h5">
157
- {emptyText}
158
- </div>
235
+ <div className={statusClass}>{emptyText}</div>
159
236
  ) : (
160
237
  filtered.map(opt => {
161
238
  const isSelected = value === opt.value;
@@ -167,13 +244,28 @@ export function SearchableSelect({
167
244
  aria-selected={isSelected}
168
245
  onClick={() => handleSelect(opt.value)}
169
246
  className={cn(
170
- 'flex w-full items-center gap-[var(--spacing-system-xs)] px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-left',
171
247
  'transition-colors hover:bg-ods-bg-hover',
248
+ isGrid
249
+ ? 'flex min-w-0 flex-col items-center justify-center gap-[var(--spacing-system-xxs)] border-b border-r border-ods-border p-[var(--spacing-system-xs)]'
250
+ : 'flex w-full items-center gap-[var(--spacing-system-xs)] px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs)] text-left',
172
251
  isSelected && 'bg-ods-bg-hover',
173
252
  )}
174
253
  >
175
254
  {renderOption ? (
176
255
  renderOption(opt, isSelected)
256
+ ) : isGrid ? (
257
+ <>
258
+ {opt.icon}
259
+ <span
260
+ className={cn(
261
+ 'w-full truncate text-center text-h6',
262
+ isSelected ? 'text-ods-text-primary' : 'text-ods-text-secondary',
263
+ )}
264
+ title={opt.label}
265
+ >
266
+ {opt.label}
267
+ </span>
268
+ </>
177
269
  ) : (
178
270
  <>
179
271
  {opt.icon}
@@ -6,7 +6,6 @@ import { useInView } from '../../hooks/ui/use-in-view';
6
6
  import { cn } from '../../utils/cn';
7
7
  import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
8
8
  import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
9
- import { PlaybackToggle } from './playback-toggle';
10
9
 
11
10
  /** The row under the track: the dots, the counter, the pause and the two arrows (the arrows set its height). */
12
11
  const SNAP_CAROUSEL_CONTROLS_CLASS = 'mt-3 flex items-center gap-2';
@@ -28,9 +27,6 @@ export interface SnapCarouselProps<T> {
28
27
  label: string;
29
28
  /** Move to the next slide after this long. `0` turns auto-advance off. Default 5000. */
30
29
  autoAdvanceMs?: number;
31
- /** Names of the stop/start control. */
32
- playLabel?: string;
33
- pauseLabel?: string;
34
30
  /** Width of one slide. Default `basis-[300px]`. */
35
31
  slideClassName?: string;
36
32
  prevLabel?: string;
@@ -67,10 +63,10 @@ function DotFill({ durationMs }: { durationMs: number }) {
67
63
  *
68
64
  * WHEN IT ADVANCES is `useAutoplay`'s rule, the same one the looping demos
69
65
  * use: mostly on screen, tab visible, not paused, motion allowed. A swipe or
70
- * an arrow moves it and it carries on from there after a full interval; it
71
- * waits only while a finger or the mouse button is actually down on it. The
72
- * small pause control stops it for good, and starts it again. While it is
73
- * auto-playing the active dot fills over the interval, so the wait is visible.
66
+ * an arrow moves it and it carries on from there after a full interval. It
67
+ * waits while the visitor holds it (`useAutoplay`'s hold: a finger or the
68
+ * mouse button down on it, the keyboard inside it); there is no pause button. While it is auto-playing the active dot fills over the
69
+ * interval, so the wait is visible.
74
70
  */
75
71
  export function SnapCarousel<T>({
76
72
  items,
@@ -78,8 +74,6 @@ export function SnapCarousel<T>({
78
74
  getKey,
79
75
  label,
80
76
  autoAdvanceMs = 5000,
81
- playLabel,
82
- pauseLabel,
83
77
  slideClassName = 'basis-[300px]',
84
78
  prevLabel = 'Previous',
85
79
  nextLabel = 'Next',
@@ -88,8 +82,6 @@ export function SnapCarousel<T>({
88
82
  }: SnapCarouselProps<T>) {
89
83
  const trackRef = useRef<HTMLDivElement | null>(null);
90
84
  const [index, setIndex] = useState(0);
91
- // Held only while a finger or the mouse button is down on it: the visitor is mid-swipe.
92
- const [pressed, setPressed] = useState(false);
93
85
  const { ref: viewRef, inView } = useInView<HTMLDivElement>({ threshold: 0.5 });
94
86
  const auto = useAutoplay(inView);
95
87
  const count = items.length;
@@ -127,24 +119,8 @@ export function SnapCarousel<T>({
127
119
  setIndex(Math.min(count - 1, Math.round(track.scrollLeft / step)));
128
120
  }, [count, slideStep]);
129
121
 
130
- // The release is listened for on the WINDOW: a press that ends outside the
131
- // carousel (a drag that leaves it, a scroll the browser takes over) must
132
- // still end, or the carousel would wait forever.
133
- useEffect(() => {
134
- if (!pressed) return undefined;
135
- const release = () => setPressed(false);
136
- window.addEventListener('pointerup', release);
137
- window.addEventListener('pointercancel', release);
138
- window.addEventListener('blur', release);
139
- return () => {
140
- window.removeEventListener('pointerup', release);
141
- window.removeEventListener('pointercancel', release);
142
- window.removeEventListener('blur', release);
143
- };
144
- }, [pressed]);
145
-
146
122
  const canAutoAdvance = autoAdvanceMs > 0 && count > 1;
147
- const playing = auto.playing && canAutoAdvance && !pressed;
123
+ const playing = auto.playing && canAutoAdvance;
148
124
 
149
125
  // Restarts on every slide change, so a swipe or an arrow is followed by a full interval.
150
126
  useEffect(() => {
@@ -164,7 +140,7 @@ export function SnapCarousel<T>({
164
140
  if (count === 0) return null;
165
141
 
166
142
  return (
167
- <div className={className} onPointerDown={() => setPressed(true)}>
143
+ <div className={className} {...auto.holdProps}>
168
144
  <div
169
145
  ref={setRefs}
170
146
  onScroll={onScroll}
@@ -212,14 +188,6 @@ export function SnapCarousel<T>({
212
188
  >
213
189
  {index + 1} / {count}
214
190
  </span>
215
- {canAutoAdvance && !auto.reducedMotion && (
216
- <PlaybackToggle
217
- paused={auto.paused}
218
- onChange={auto.setPaused}
219
- playLabel={playLabel}
220
- pauseLabel={pauseLabel}
221
- />
222
- )}
223
191
  <button
224
192
  type="button"
225
193
  aria-label={prevLabel}
@@ -58,10 +58,17 @@ export interface TabItem {
58
58
  /**
59
59
  * 0..1. Set on the ACTIVE tab, the underline stops being a selection marker
60
60
  * and becomes a progress line: its width is this fraction of the tab, and it
61
- * moves there linearly over `progressTransitionMs`. A timed, auto-advancing
61
+ * moves there linearly over `progressTransitionMs`. Start it above 0 (the
62
+ * first step of N is 1/N): at 0 the open tab has no underline at all. A timed, auto-advancing
62
63
  * strip (a looping demo) uses it; `0` snaps back with no animation.
63
64
  */
64
65
  progress?: number;
66
+ /**
67
+ * The tab's own colour (a CSS colour, such as the hex an agent's identity
68
+ * stores): its underline and its icon take it while the tab is open. Omitted,
69
+ * they are the platform's accent.
70
+ */
71
+ accent?: string;
65
72
  }
66
73
 
67
74
  export interface TabNavigationUrlSyncOptions {
@@ -334,7 +341,34 @@ const TabBar = memo(function TabBarImpl({
334
341
  // A tab that reports `progress` turns the underline into a progress line.
335
342
  const activeProgress = tabs.find(tab => tab.id === activeTab)?.progress;
336
343
  const hasProgress = typeof activeProgress === 'number';
337
- const progress = hasProgress ? Math.max(0, Math.min(1, activeProgress)) : 1;
344
+ // A progress line always GROWS from the start of its tab. A run that starts
345
+ // over (another tab opened, by the rotation or by choice, or the same tab
346
+ // chosen again: its progress fell back) is first drawn EMPTY, with no
347
+ // transition, and only then let run to its value. Without that the line
348
+ // either slid across from the last tab, shrank back, or appeared already
349
+ // part-way and sat still until the next step.
350
+ const target = hasProgress ? Math.max(0, Math.min(1, activeProgress)) : 1;
351
+ // It starts on no tab at all, so the FIRST paint is a run starting over too:
352
+ // a page load, the rotation and a click all begin the line the same way.
353
+ const [run, setRun] = useState<{ tab: string | null; progress: number }>({ tab: null, progress: 0 });
354
+ const startsOver = hasProgress && (run.tab !== activeTab || target < run.progress);
355
+ // The same run moving forward: follow it (state derived from props during render).
356
+ if (hasProgress && !startsOver && run.progress !== target) setRun({ tab: activeTab, progress: target });
357
+ useEffect(() => {
358
+ if (!startsOver) return undefined;
359
+ // Two frames: the empty line has to be painted before it is let run.
360
+ let second = 0;
361
+ const first = requestAnimationFrame(() => {
362
+ second = requestAnimationFrame(() => setRun({ tab: activeTab, progress: target }));
363
+ });
364
+ return () => {
365
+ cancelAnimationFrame(first);
366
+ cancelAnimationFrame(second);
367
+ };
368
+ }, [startsOver, activeTab, target]);
369
+ const progress = startsOver ? 0 : target;
370
+ // The open tab's own colour, when it has one; else the platform's accent.
371
+ const activeAccent = tabs.find(tab => tab.id === activeTab)?.accent;
338
372
  const indicatorStyle: React.CSSProperties = {
339
373
  width: `${indicator.width * progress}px`,
340
374
  transform: `translateX(${indicator.left}px)`,
@@ -362,7 +396,7 @@ const TabBar = memo(function TabBarImpl({
362
396
  `relative` makes this the offsetParent the underline is measured against. */}
363
397
  <div
364
398
  ref={scrollRef}
365
- className="scrollbar-hide relative flex h-full items-center justify-start gap-[var(--spacing-system-xxs)] overflow-x-auto overflow-y-hidden"
399
+ className="scrollbar-hide relative flex h-full items-center justify-start overflow-x-auto overflow-y-hidden"
366
400
  >
367
401
  {tabs.map(tab => {
368
402
  const isActive = activeTab === tab.id;
@@ -375,28 +409,19 @@ const TabBar = memo(function TabBarImpl({
375
409
  onClick={() => onTabChange(tab.id)}
376
410
  aria-label={tab.badge ? `${tab.label} (${tab.badge})` : undefined}
377
411
  className={cn(
378
- 'relative flex shrink-0 cursor-pointer items-center gap-[var(--spacing-system-xxs)] p-[var(--spacing-system-m)]',
412
+ 'group relative flex shrink-0 cursor-pointer items-center gap-[var(--spacing-system-xxs)] p-[var(--spacing-system-m)]',
379
413
  tabAlign === 'start' ? 'justify-start text-left' : 'justify-center',
380
414
  // Named rather than `transition-all`: the only thing that moves
381
- // here is colour. `all` also made the browser re-check every
382
- // property each frame, including the background GRADIENT — which
383
- // never animated anyway, since the inactive state has no
384
- // background-image to interpolate from.
415
+ // here is colour.
385
416
  'border-none bg-transparent outline-none transition-colors duration-200',
386
417
  // `outline-none` above drops the browser's own focus ring, so
387
418
  // put one back or the strip is un-navigable by keyboard — you
388
419
  // can tab through it with nothing to show where you are. Inset,
389
- // so it stays within the button instead of overlapping the tab
390
- // sitting 4px away.
420
+ // so it stays within the button: the tabs touch.
391
421
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus',
392
422
  stretchTabs && 'flex-1',
393
423
  tab.avatar && 'gap-[var(--spacing-system-xsf)]',
394
- // Known limitation: ODS color vars hold hex values, so Tailwind
395
- // alpha modifiers (to-ods-accent/10) silently produce no CSS.
396
- // color-mix() accepts hex, so we derive the 10%-alpha stop from the token.
397
- isActive
398
- ? 'bg-gradient-to-b from-transparent to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
399
- : 'hover:bg-gradient-to-b hover:from-transparent hover:to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]',
424
+ // The open tab is marked by the underline alone: no surface of its own.
400
425
  )}
401
426
  >
402
427
  {tab.avatar ? (
@@ -409,8 +434,11 @@ const TabBar = memo(function TabBarImpl({
409
434
  <tab.icon
410
435
  className={cn(
411
436
  'h-4 w-4 transition-colors content-md:h-6 content-md:w-6',
412
- isActive ? 'text-ods-accent' : 'text-ods-text-secondary',
437
+ isActive
438
+ ? !tab.accent && 'text-ods-accent'
439
+ : 'text-ods-text-secondary group-hover:text-ods-text-primary',
413
440
  )}
441
+ style={isActive && tab.accent ? { color: tab.accent } : undefined}
414
442
  />
415
443
  {tab.indicator && <StatusDot indicator={tab.indicator} className="absolute right-0 top-[-3px]" />}
416
444
  </div>
@@ -425,7 +453,7 @@ const TabBar = memo(function TabBarImpl({
425
453
  <span
426
454
  className={cn(
427
455
  'whitespace-nowrap transition-colors text-h4',
428
- isActive ? 'text-ods-text-primary' : 'text-ods-text-secondary',
456
+ isActive ? 'text-ods-text-primary' : 'text-ods-text-secondary group-hover:text-ods-text-primary',
429
457
  )}
430
458
  >
431
459
  {tab.label}
@@ -471,11 +499,11 @@ const TabBar = memo(function TabBarImpl({
471
499
  // tests) rather than incidental styling.
472
500
  data-testid="tab-navigation-underline"
473
501
  className={cn(
474
- 'pointer-events-none absolute bottom-0 left-0 bg-ods-accent motion-reduce:transition-none',
475
- hasProgress ? 'h-0.5' : 'h-1',
502
+ 'pointer-events-none absolute bottom-0 left-0 h-1 motion-reduce:transition-none',
503
+ !activeAccent && 'bg-ods-accent',
476
504
  !hasProgress && shouldAnimateIndicator && 'transition-[transform,width,opacity] duration-200 ease-out',
477
505
  )}
478
- style={indicatorStyle}
506
+ style={activeAccent ? { ...indicatorStyle, backgroundColor: activeAccent } : indicatorStyle}
479
507
  />
480
508
  </div>
481
509