@flamingo-stack/openframe-frontend-core 0.0.577 → 0.0.578-snapshot.20260831161155

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 (228) hide show
  1. package/dist/{chunk-XBSW7IF6.cjs → chunk-36TJYCF3.cjs} +27 -27
  2. package/dist/{chunk-XBSW7IF6.cjs.map → chunk-36TJYCF3.cjs.map} +1 -1
  3. package/dist/{chunk-ET3Q4FIZ.cjs → chunk-37XJYOAY.cjs} +17 -17
  4. package/dist/{chunk-ET3Q4FIZ.cjs.map → chunk-37XJYOAY.cjs.map} +1 -1
  5. package/dist/{chunk-4D2BR756.js → chunk-3AX3ZEY3.js} +53 -32
  6. package/dist/chunk-3AX3ZEY3.js.map +1 -0
  7. package/dist/{chunk-JQI726T3.js → chunk-3LMYQGAJ.js} +3 -2
  8. package/dist/chunk-3LMYQGAJ.js.map +1 -0
  9. package/dist/{chunk-XBBCUC32.cjs → chunk-4FJSN2XW.cjs} +15 -15
  10. package/dist/{chunk-XBBCUC32.cjs.map → chunk-4FJSN2XW.cjs.map} +1 -1
  11. package/dist/{chunk-RCLJSENK.cjs → chunk-4HGRXFQP.cjs} +6 -6
  12. package/dist/{chunk-RCLJSENK.cjs.map → chunk-4HGRXFQP.cjs.map} +1 -1
  13. package/dist/{chunk-NQCGDZJ3.cjs → chunk-4LZ7B5H5.cjs} +5 -5
  14. package/dist/{chunk-NQCGDZJ3.cjs.map → chunk-4LZ7B5H5.cjs.map} +1 -1
  15. package/dist/{chunk-IQFXDS5O.js → chunk-4WSKSNXL.js} +10 -10
  16. package/dist/{chunk-NEKT67O5.js → chunk-5Y6TPFW7.js} +6 -6
  17. package/dist/{chunk-XG2WNF4L.cjs → chunk-A6C5HOD7.cjs} +27 -27
  18. package/dist/{chunk-XG2WNF4L.cjs.map → chunk-A6C5HOD7.cjs.map} +1 -1
  19. package/dist/{chunk-AQ5MZVYN.js → chunk-AKPLEWMH.js} +3 -3
  20. package/dist/{chunk-HSOMYCUG.cjs → chunk-BCJQ7CJE.cjs} +13 -13
  21. package/dist/{chunk-HSOMYCUG.cjs.map → chunk-BCJQ7CJE.cjs.map} +1 -1
  22. package/dist/{chunk-RUJKOU5F.js → chunk-BW4WDUSI.js} +3 -3
  23. package/dist/{chunk-NP3XN6ZX.js → chunk-CP5EYMNL.js} +278 -162
  24. package/dist/chunk-CP5EYMNL.js.map +1 -0
  25. package/dist/{chunk-FCVAVOXS.js → chunk-D44S2QLI.js} +3 -3
  26. package/dist/{chunk-U3SDSA5K.js → chunk-DB3TV4CW.js} +7 -7
  27. package/dist/{chunk-KE2TCUOH.cjs → chunk-FEVLXRTC.cjs} +537 -421
  28. package/dist/chunk-FEVLXRTC.cjs.map +1 -0
  29. package/dist/{chunk-ZBJ6XSDO.js → chunk-H4BPHGU5.js} +4 -4
  30. package/dist/{chunk-6FJABKPX.cjs → chunk-H54EPP3P.cjs} +9 -9
  31. package/dist/{chunk-6FJABKPX.cjs.map → chunk-H54EPP3P.cjs.map} +1 -1
  32. package/dist/{chunk-4KR7JWKB.js → chunk-HGA74X35.js} +5 -5
  33. package/dist/{chunk-5DPCIIJ7.cjs → chunk-HHJVIHVR.cjs} +92 -92
  34. package/dist/{chunk-5DPCIIJ7.cjs.map → chunk-HHJVIHVR.cjs.map} +1 -1
  35. package/dist/{chunk-WOHHZZJK.cjs → chunk-IFYQ7DHV.cjs} +62 -62
  36. package/dist/{chunk-WOHHZZJK.cjs.map → chunk-IFYQ7DHV.cjs.map} +1 -1
  37. package/dist/{chunk-QYQXAXVR.cjs → chunk-IKDC3R7V.cjs} +4 -3
  38. package/dist/chunk-IKDC3R7V.cjs.map +1 -0
  39. package/dist/{chunk-W6JA6SFD.js → chunk-JRYS4VMV.js} +45 -45
  40. package/dist/chunk-JRYS4VMV.js.map +1 -0
  41. package/dist/{chunk-2YCUEUIZ.js → chunk-L2CF2QZW.js} +8 -8
  42. package/dist/{chunk-2BFZHTSD.cjs → chunk-LEISUAHF.cjs} +18 -8
  43. package/dist/chunk-LEISUAHF.cjs.map +1 -0
  44. package/dist/{chunk-GDWBJFMG.cjs → chunk-M5GWCMYE.cjs} +42 -42
  45. package/dist/{chunk-GDWBJFMG.cjs.map → chunk-M5GWCMYE.cjs.map} +1 -1
  46. package/dist/{chunk-JCH6HGQD.js → chunk-MCWAZ5XI.js} +2 -2
  47. package/dist/{chunk-PQETCTJ4.js → chunk-MXOSTJAG.js} +4 -3
  48. package/dist/chunk-MXOSTJAG.js.map +1 -0
  49. package/dist/{chunk-MIY5FYX3.cjs → chunk-NNFQLTJ3.cjs} +4 -3
  50. package/dist/chunk-NNFQLTJ3.cjs.map +1 -0
  51. package/dist/{chunk-CFTZAZ7R.cjs → chunk-NPVDKOGD.cjs} +28 -28
  52. package/dist/{chunk-CFTZAZ7R.cjs.map → chunk-NPVDKOGD.cjs.map} +1 -1
  53. package/dist/{chunk-LYOGSVFO.js → chunk-O2FTVZOD.js} +5 -5
  54. package/dist/{chunk-CIUYHBAY.cjs → chunk-OGZAFM6M.cjs} +5 -5
  55. package/dist/{chunk-CIUYHBAY.cjs.map → chunk-OGZAFM6M.cjs.map} +1 -1
  56. package/dist/{chunk-THDGDYJ3.js → chunk-PMENIZXI.js} +7 -7
  57. package/dist/{chunk-TU32TSAK.js → chunk-PWOYEUIQ.js} +7 -7
  58. package/dist/{chunk-RSMUAAPW.js → chunk-QCP2KV7P.js} +5 -5
  59. package/dist/{chunk-RNUH5IHZ.js → chunk-QGJYWLF4.js} +3 -3
  60. package/dist/{chunk-75X6BRID.cjs → chunk-RKGOVTXZ.cjs} +500 -479
  61. package/dist/chunk-RKGOVTXZ.cjs.map +1 -0
  62. package/dist/{chunk-HM2R5MDR.js → chunk-T5HDPHN6.js} +13 -3
  63. package/dist/chunk-T5HDPHN6.js.map +1 -0
  64. package/dist/{chunk-GPWU3PPR.cjs → chunk-VEHZNQ77.cjs} +30 -30
  65. package/dist/{chunk-GPWU3PPR.cjs.map → chunk-VEHZNQ77.cjs.map} +1 -1
  66. package/dist/{chunk-APFGR2KK.cjs → chunk-YW4WBNYV.cjs} +41 -41
  67. package/dist/{chunk-APFGR2KK.cjs.map → chunk-YW4WBNYV.cjs.map} +1 -1
  68. package/dist/{chunk-36NFJULV.cjs → chunk-ZI27ALYX.cjs} +41 -41
  69. package/dist/chunk-ZI27ALYX.cjs.map +1 -0
  70. package/dist/components/case-studies/index.cjs +13 -13
  71. package/dist/components/case-studies/index.js +7 -7
  72. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  73. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  75. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  76. package/dist/components/chat/index.cjs +4 -4
  77. package/dist/components/chat/index.js +3 -3
  78. package/dist/components/chat/nav-link-anchor-via-runtime.d.ts.map +1 -1
  79. package/dist/components/chat/types/unified-chat-state.types.d.ts +9 -2
  80. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  81. package/dist/components/chat/utils/chat-nav-resolution.d.ts +9 -3
  82. package/dist/components/chat/utils/chat-nav-resolution.d.ts.map +1 -1
  83. package/dist/components/chat/utils/execute-navigation.d.ts +11 -0
  84. package/dist/components/chat/utils/execute-navigation.d.ts.map +1 -1
  85. package/dist/components/chat/utils/index.d.ts +1 -1
  86. package/dist/components/chat/utils/index.d.ts.map +1 -1
  87. package/dist/components/chat/utils/nav-anchor-props.d.ts +4 -0
  88. package/dist/components/chat/utils/nav-anchor-props.d.ts.map +1 -1
  89. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +42 -3
  90. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -1
  91. package/dist/components/contact/index.cjs +8 -8
  92. package/dist/components/contact/index.js +7 -7
  93. package/dist/components/docs/index.cjs +11 -11
  94. package/dist/components/docs/index.js +10 -10
  95. package/dist/components/embeds/index.cjs +8 -8
  96. package/dist/components/embeds/index.js +7 -7
  97. package/dist/components/faq/index.cjs +8 -8
  98. package/dist/components/faq/index.js +7 -7
  99. package/dist/components/features/board/board.d.ts.map +1 -1
  100. package/dist/components/features/index.cjs +9 -7
  101. package/dist/components/features/index.cjs.map +1 -1
  102. package/dist/components/features/index.js +8 -6
  103. package/dist/components/features/sortable-list/index.d.ts +2 -1
  104. package/dist/components/features/sortable-list/index.d.ts.map +1 -1
  105. package/dist/components/features/sortable-list/sortable-list.d.ts +8 -1
  106. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -1
  107. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts +24 -0
  108. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts.map +1 -0
  109. package/dist/components/features/sortable-list/use-sortable-item.d.ts +12 -4
  110. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -1
  111. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts +21 -5
  112. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts.map +1 -1
  113. package/dist/components/help-center-pages/index.cjs +27 -27
  114. package/dist/components/help-center-pages/index.js +18 -18
  115. package/dist/components/index.cjs +146 -144
  116. package/dist/components/index.cjs.map +1 -1
  117. package/dist/components/index.js +20 -18
  118. package/dist/components/index.js.map +1 -1
  119. package/dist/components/meeting-scheduler/index.cjs +47 -47
  120. package/dist/components/meeting-scheduler/index.js +6 -6
  121. package/dist/components/navigation/index.cjs +10 -10
  122. package/dist/components/navigation/index.js +9 -9
  123. package/dist/components/onboarding-guides/index.cjs +10 -10
  124. package/dist/components/onboarding-guides/index.js +9 -9
  125. package/dist/components/related-content/index.cjs +3 -3
  126. package/dist/components/related-content/index.js +2 -2
  127. package/dist/components/tickets/index.cjs +11 -11
  128. package/dist/components/tickets/index.js +10 -10
  129. package/dist/components/ui/file-manager/index.cjs +4 -4
  130. package/dist/components/ui/file-manager/index.js +1 -1
  131. package/dist/components/ui/index.cjs +7 -7
  132. package/dist/components/ui/index.js +6 -6
  133. package/dist/components/ui/input.d.ts +2 -0
  134. package/dist/components/ui/input.d.ts.map +1 -1
  135. package/dist/components/ui/textarea.d.ts +2 -0
  136. package/dist/components/ui/textarea.d.ts.map +1 -1
  137. package/dist/components/ui/ticket-status-config-row.d.ts +10 -4
  138. package/dist/components/ui/ticket-status-config-row.d.ts.map +1 -1
  139. package/dist/hooks/index.cjs +7 -3
  140. package/dist/hooks/index.cjs.map +1 -1
  141. package/dist/hooks/index.js +6 -2
  142. package/dist/hooks/ui/index.d.ts +1 -0
  143. package/dist/hooks/ui/index.d.ts.map +1 -1
  144. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts +31 -0
  145. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts.map +1 -0
  146. package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
  147. package/dist/index.cjs +18 -12
  148. package/dist/index.cjs.map +1 -1
  149. package/dist/index.js +16 -10
  150. package/dist/index.js.map +1 -1
  151. package/dist/schemas/meeting-booking-schema.d.ts +2 -2
  152. package/dist/utils/content-href.d.ts +26 -5
  153. package/dist/utils/content-href.d.ts.map +1 -1
  154. package/dist/utils/index.cjs +1 -1
  155. package/dist/utils/index.cjs.map +1 -1
  156. package/dist/utils/index.d.ts +1 -1
  157. package/dist/utils/index.d.ts.map +1 -1
  158. package/dist/utils/index.js +1 -1
  159. package/dist/utils/index.js.map +1 -1
  160. package/package.json +1 -4
  161. package/src/components/chat/embeddable-chat.tsx +9 -2
  162. package/src/components/chat/entity-cards/.dispatch.md +1 -1
  163. package/src/components/chat/entity-cards/dispatch.tsx +43 -23
  164. package/src/components/chat/hooks/.use-unified-chat.md +5 -1
  165. package/src/components/chat/hooks/use-nats-chat-adapter.ts +4 -7
  166. package/src/components/chat/hooks/use-unified-chat.ts +8 -2
  167. package/src/components/chat/nav-link-anchor-via-runtime.tsx +8 -10
  168. package/src/components/chat/types/.unified-chat-state.types.md +2 -1
  169. package/src/components/chat/types/unified-chat-state.types.ts +9 -2
  170. package/src/components/chat/utils/__tests__/execute-navigation.test.ts +190 -0
  171. package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +75 -2
  172. package/src/components/chat/utils/chat-nav-resolution.ts +9 -3
  173. package/src/components/chat/utils/execute-navigation.ts +32 -2
  174. package/src/components/chat/utils/index.ts +1 -1
  175. package/src/components/chat/utils/nav-anchor-props.ts +4 -2
  176. package/src/components/chat/utils/resolve-fetched-card-href.ts +50 -2
  177. package/src/components/features/board/.board.md +1 -0
  178. package/src/components/features/board/board.tsx +11 -1
  179. package/src/components/features/sortable-list/.sortable-list.md +23 -12
  180. package/src/components/features/sortable-list/index.ts +2 -1
  181. package/src/components/features/sortable-list/sortable-list.tsx +16 -5
  182. package/src/components/features/sortable-list/sortable-move-buttons.tsx +81 -0
  183. package/src/components/features/sortable-list/use-sortable-item.ts +25 -13
  184. package/src/components/features/ticket-status-config-list/ticket-status-config-list.tsx +55 -62
  185. package/src/components/ui/.ticket-status-config-row.md +11 -9
  186. package/src/components/ui/input.tsx +5 -2
  187. package/src/components/ui/textarea.tsx +5 -2
  188. package/src/components/ui/ticket-status-config-row.tsx +162 -70
  189. package/src/hooks/ui/.index.md +1 -0
  190. package/src/hooks/ui/.use-drag-and-drop-enabled.md +32 -0
  191. package/src/hooks/ui/index.ts +1 -0
  192. package/src/hooks/ui/use-drag-and-drop-enabled.ts +37 -0
  193. package/src/hooks/ui/use-media-query.ts +3 -2
  194. package/src/utils/.content-href.md +1 -1
  195. package/src/utils/__tests__/content-href.test.ts +3 -0
  196. package/src/utils/content-href.ts +30 -3
  197. package/src/utils/index.ts +1 -0
  198. package/dist/chunk-2BFZHTSD.cjs.map +0 -1
  199. package/dist/chunk-36NFJULV.cjs.map +0 -1
  200. package/dist/chunk-4D2BR756.js.map +0 -1
  201. package/dist/chunk-75X6BRID.cjs.map +0 -1
  202. package/dist/chunk-HM2R5MDR.js.map +0 -1
  203. package/dist/chunk-JQI726T3.js.map +0 -1
  204. package/dist/chunk-KE2TCUOH.cjs.map +0 -1
  205. package/dist/chunk-MIY5FYX3.cjs.map +0 -1
  206. package/dist/chunk-NP3XN6ZX.js.map +0 -1
  207. package/dist/chunk-PQETCTJ4.js.map +0 -1
  208. package/dist/chunk-QYQXAXVR.cjs.map +0 -1
  209. package/dist/chunk-W6JA6SFD.js.map +0 -1
  210. package/dist/components/chat/utils/nav-click-handler.d.ts +0 -24
  211. package/dist/components/chat/utils/nav-click-handler.d.ts.map +0 -1
  212. package/src/components/chat/utils/.nav-click-handler.md +0 -43
  213. package/src/components/chat/utils/nav-click-handler.ts +0 -34
  214. /package/dist/{chunk-IQFXDS5O.js.map → chunk-4WSKSNXL.js.map} +0 -0
  215. /package/dist/{chunk-NEKT67O5.js.map → chunk-5Y6TPFW7.js.map} +0 -0
  216. /package/dist/{chunk-AQ5MZVYN.js.map → chunk-AKPLEWMH.js.map} +0 -0
  217. /package/dist/{chunk-RUJKOU5F.js.map → chunk-BW4WDUSI.js.map} +0 -0
  218. /package/dist/{chunk-FCVAVOXS.js.map → chunk-D44S2QLI.js.map} +0 -0
  219. /package/dist/{chunk-U3SDSA5K.js.map → chunk-DB3TV4CW.js.map} +0 -0
  220. /package/dist/{chunk-ZBJ6XSDO.js.map → chunk-H4BPHGU5.js.map} +0 -0
  221. /package/dist/{chunk-4KR7JWKB.js.map → chunk-HGA74X35.js.map} +0 -0
  222. /package/dist/{chunk-2YCUEUIZ.js.map → chunk-L2CF2QZW.js.map} +0 -0
  223. /package/dist/{chunk-JCH6HGQD.js.map → chunk-MCWAZ5XI.js.map} +0 -0
  224. /package/dist/{chunk-LYOGSVFO.js.map → chunk-O2FTVZOD.js.map} +0 -0
  225. /package/dist/{chunk-THDGDYJ3.js.map → chunk-PMENIZXI.js.map} +0 -0
  226. /package/dist/{chunk-TU32TSAK.js.map → chunk-PWOYEUIQ.js.map} +0 -0
  227. /package/dist/{chunk-RSMUAAPW.js.map → chunk-QCP2KV7P.js.map} +0 -0
  228. /package/dist/{chunk-RNUH5IHZ.js.map → chunk-QGJYWLF4.js.map} +0 -0
@@ -31,7 +31,12 @@
31
31
  import type { ChatRuntime } from '../../../contexts/chat-runtime-context';
32
32
  import { navigateSamePageHash, STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';
33
33
  import { NEW_TAB_FEATURES, stripSameOriginToPath } from './chat-nav-resolution';
34
- import { computeIsNewTab } from './nav-anchor-props';
34
+ import { computeIsNewTab, isSameOriginAbsoluteHref } from './nav-anchor-props';
35
+
36
+ /** Absolute `http(s)` URL pointing at an origin that is not ours. */
37
+ function isForeignOriginHref(href: string): boolean {
38
+ return /^https?:\/\//i.test(href) && !isSameOriginAbsoluteHref(href);
39
+ }
35
40
 
36
41
  /** Minimal mouse-event surface — structural so chip buttons / tiles can call it
37
42
  * without casting `React.MouseEvent`. */
@@ -78,7 +83,12 @@ function runNavigation(
78
83
  else window.open(href, '_blank', NEW_TAB_FEATURES);
79
84
  return;
80
85
  }
81
- const target = stripSameOriginToPath(href);
86
+ // A FOREIGN href that the host's `decideNewTab` routed same-tab must reach the
87
+ // router whole: stripping its origin pushes `/webinars/x` onto OUR origin and
88
+ // 404s. Same-origin absolutes still get stripped, so an in-app soft nav stays
89
+ // one. (`stripSameOriginToPath` itself strips unconditionally on purpose — the
90
+ // hub is one app across several origins and depends on that.)
91
+ const target = isForeignOriginHref(href) ? href : stripSameOriginToPath(href);
82
92
  // Same-page hash target (e.g. a chat card deep-linking to `/faqs#faq-item-49`):
83
93
  // route through the UNIFIED same-page-hash primitive FIRST. A host router /
84
94
  // `router.push` performs `pushState`, which the HTML spec says does NOT fire a
@@ -139,3 +149,23 @@ export function executeNavigationImperative({
139
149
  }: ExecuteNavigationImperativeArgs): void {
140
150
  runNavigation(runtime, href, undefined, targetPlatform, fallbackNavigate);
141
151
  }
152
+
153
+ /**
154
+ * @deprecated Call {@link executeNavigation} directly. Kept only so external
155
+ * embedders' imports keep resolving; no lib code path goes through it.
156
+ */
157
+ export function handleChatNavClick(
158
+ event: NavClickEvent,
159
+ runtime: ChatRuntime | null | undefined,
160
+ { href, path, targetPlatform }: ChatNavClickInput,
161
+ fallbackNavigate?: (path: string) => void,
162
+ ): boolean {
163
+ return executeNavigation({ event, runtime, href, path, targetPlatform, fallbackNavigate });
164
+ }
165
+
166
+ /** @deprecated Argument shape of the deprecated {@link handleChatNavClick}. */
167
+ export interface ChatNavClickInput {
168
+ href: string;
169
+ path?: string | null;
170
+ targetPlatform?: string | null;
171
+ }
@@ -59,7 +59,7 @@ export {
59
59
  resolveExternalNavigation,
60
60
  type ExternalNavResolution,
61
61
  } from './chat-nav-resolution';
62
- export { handleChatNavClick, type ChatNavClickInput } from './nav-click-handler';
62
+ export { handleChatNavClick, type ChatNavClickInput } from './execute-navigation';
63
63
  // The unified navigation-execution primitive — single source of truth for
64
64
  // new-tab/same-tab + internal/external + embed/host + the runtime→router→window
65
65
  // fallback. Every surface routes through it (handleChatNavClick is now a wrapper).
@@ -33,11 +33,13 @@ import { decideNewTab as libDecideNewTab } from './decide-new-tab';
33
33
  /** An ABSOLUTE `http(s)` URL on the current page's origin. Relative hrefs return
34
34
  * false (in embed mode they're hub-relative, not in-app), as do cross-origin
35
35
  * and non-http URLs. SSR-safe: no `window` → false. */
36
- function isSameOriginAbsoluteHref(href: string): boolean {
36
+ export function isSameOriginAbsoluteHref(href: string): boolean {
37
37
  if (typeof window === 'undefined') return false;
38
38
  if (!/^https?:\/\//i.test(href)) return false;
39
39
  try {
40
- return new URL(href).origin === window.location.origin;
40
+ // Origin off `location.href`, not `location.origin`: jsdom leaves the latter
41
+ // undefined, which reads as "every URL is foreign" under test.
42
+ return new URL(href).origin === new URL(window.location.href).origin;
41
43
  } catch {
42
44
  return false;
43
45
  }
@@ -31,7 +31,7 @@
31
31
  * Pure — no React, no DOM. The caller owns `safeHref` validation.
32
32
  */
33
33
 
34
- import type { ComposeContentUrl } from '../../../utils/content-href';
34
+ import type { ComposeContentUrl, ComposedContentUrl } from '../../../utils/content-href';
35
35
  import { canonicalContentRefType } from '../../../utils/list-url';
36
36
 
37
37
  export interface FetchedCardHrefInput {
@@ -90,12 +90,60 @@ function readPlatforms(item: unknown, canonicalType: string): Array<{ name?: str
90
90
  * when the host wired no seam (the caller then keeps the card unlinked, which
91
91
  * is the pre-existing behavior).
92
92
  */
93
+ /**
94
+ * Where a fetch-mode card's destination came from. The loader needs the source,
95
+ * not just the string: only the `item` branch must leave the ref's own
96
+ * `targetPlatform` untouched.
97
+ */
98
+ export type FetchedCardHrefChoice =
99
+ | { source: 'hostOverride'; href: string; targetPlatform: string | null }
100
+ | { source: 'item'; href: string; targetPlatform?: undefined }
101
+ | { source: 'composed'; href: string; targetPlatform: string | null };
102
+
103
+ /**
104
+ * Decide a fetched card's destination between the two producers that can claim
105
+ * it. Pure, so the precedence is testable on its own — it used to live as
106
+ * inline ternaries in `ChatCardLoader`.
107
+ *
108
+ * 1. an EXPLICIT host override — the host said where this type lives in its
109
+ * own app, which beats everything;
110
+ * 2. the fetched row's own url (`registry.fallbackHref`) — minted by the
111
+ * content host for ITS surface, authoritative in the absence of (1);
112
+ * 3. the composer's synthesized href, unless the registry opted out
113
+ * (`noComposedHref`) because the synthesis would be a 404.
114
+ *
115
+ * (2) beating (3) is why `noComposedHref` exists; (1) beating (2) is what keeps
116
+ * a re-homed type (an embedder serving hub tickets under `/help-center/tickets`)
117
+ * from following the content host's path into an unrelated route of its own.
118
+ */
119
+ export function pickFetchedCardHref({
120
+ composed,
121
+ itemHref,
122
+ allowComposed,
123
+ }: {
124
+ /** Result of {@link resolveFetchedCardHref}, or null when unresolvable. */
125
+ composed: ComposedContentUrl | null;
126
+ /** `registry.fallbackHref(item)` — the fetched row's own destination. */
127
+ itemHref: string | null;
128
+ /** False for registry entries flagged `noComposedHref`. */
129
+ allowComposed: boolean;
130
+ }): FetchedCardHrefChoice | null {
131
+ if (composed?.hostOverride) {
132
+ return { source: 'hostOverride', href: composed.href, targetPlatform: composed.targetPlatform };
133
+ }
134
+ if (itemHref) return { source: 'item', href: itemHref };
135
+ if (composed && allowComposed) {
136
+ return { source: 'composed', href: composed.href, targetPlatform: composed.targetPlatform };
137
+ }
138
+ return null;
139
+ }
140
+
93
141
  export function resolveFetchedCardHref({
94
142
  contentRefType,
95
143
  id,
96
144
  item,
97
145
  composeContentUrl,
98
- }: FetchedCardHrefInput): { href: string; targetPlatform: string | null } | null {
146
+ }: FetchedCardHrefInput): ComposedContentUrl | null {
99
147
  if (!composeContentUrl || !contentRefType || !id) return null;
100
148
  const canonicalType = canonicalContentRefType(contentRefType);
101
149
  return composeContentUrl({
@@ -15,6 +15,7 @@ Kanban-style drag-and-drop board built on Pragmatic drag and drop, managing mult
15
15
  - **Nothing moves during a drag** — The dragged card stays put and fades; the landing place is a line. No margins open, no slots close, so the lane's height cannot wander and an edge flip costs one span instead of a relayout plus a card render
16
16
  - **One aim, one preview** — Where the drop points is resolved once, in the board's monitor, and handed down (`drop-aim.ts`). Each card used to work it out from its own hover events, which only ever answers for a card the pointer is physically over — so the preview vanished the moment the pointer left the lanes, even though the drop would still have landed. The board's answer is compared before it is stored, so a pointer moving inside one card does not re-render anything
17
17
  - **Cross-column guards** — `dropDisabled` and `allowedFromColumns` are enforced by each target's `canDrop`, so an invalid drop is never accepted and nothing has to be reverted
18
+ - **No DnD on touch** — `useDragAndDropEnabled()` gates the whole board: on touch/narrow viewports (`(pointer: fine) and (hover: hover) and (min-width: 800px)` fails) every column is forced `dragDisabled`/`dropDisabled`, so nothing registers a draggable or a target — the HTML5 drag events Pragmatic builds on never fire under touch anyway. Card moves there go through the ticket itself, not the board
18
19
  - **Optimistic view** — The board renders `applyPendingMove(columns, pendingMove)` from the drop until the host's answer arrives (`pending-move.ts`), so the move is on screen instantly and the host's data replaces an identical picture. "Landed" is judged per kind of move: across lanes, arriving in the new lane once the old one has let go (a server may re-sort the lane, so the exact index would never come); within one lane, the exact slot. Weaker tests (a fresh `columns` array, the card leaving its old index) fire on a lane's own poll and snap the card back mid-flight. A 2s timeout is the backstop for a host that never applies the change
19
20
  - **Dragged card** — The card under the pointer is the browser's own native drag image (a styled DOM clone via `setCustomNativeDragPreview`), so the browser moves it — over its own chrome included — and carrying a card re-renders nothing and cannot freeze at the viewport edge. On drop, a portal copy takes over for one 160ms FLIP glide from the release point into the slot the move just opened, then hands over to the real card (hidden under it via `LandingCardContext` for exactly that long)
20
21
  - **Keyboard move** — Space on a focused card lifts it; the arrows walk it (up/down through slots, left/right through lanes, skipping any that will not take it); space or enter drops it; escape puts it back. Lift-and-place rather than one-press-one-move, because on a board every press would otherwise be its own write to the server. The lifted card feeds the same aim a pointer drag does, and the drop line is scrolled into view on every step — arrow keys bring no auto-scroll of their own. Keys are handled on the board, not the card: once a card is lifted they belong to the move rather than to whatever holds focus. Rules live in `aim.ts`, pure and unit-tested
@@ -5,6 +5,7 @@ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/ad
5
5
  import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/utils/prevent-unhandled';
6
6
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
7
7
  import { createPortal } from 'react-dom';
8
+ import { useDragAndDropEnabled } from '../../../hooks/ui/use-drag-and-drop-enabled';
8
9
  import { useHorizontalScrollbar } from '../../../hooks/ui/use-horizontal-scrollbar';
9
10
  import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect';
10
11
  import { autoScrollBothWays } from '../../../utils/auto-scroll-ancestors';
@@ -169,7 +170,16 @@ export function Board({
169
170
  // Publishing it from an effect instead committed one whole frame with the card
170
171
  // still held in its pending position after the host had already caught up.
171
172
  const pendingMove = reportedMove && !hasMoveSettled(columns, reportedMove) ? reportedMove : null;
172
- const view = useMemo(() => (pendingMove ? applyPendingMove(columns, pendingMove) : columns), [columns, pendingMove]);
173
+ // On touch/narrow viewports no card is draggable and no lane is a drop
174
+ // target: the HTML5 drag events Pragmatic builds on never fire under touch,
175
+ // so instead of shipping a drag that half-works, none is initialized at all.
176
+ // Card moves there go through the ticket itself (its dialog), not the board.
177
+ const dragAndDropEnabled = useDragAndDropEnabled();
178
+ const view = useMemo(() => {
179
+ const base = pendingMove ? applyPendingMove(columns, pendingMove) : columns;
180
+ if (dragAndDropEnabled) return base;
181
+ return base.map(column => ({ ...column, dragDisabled: true, dropDisabled: true }));
182
+ }, [columns, pendingMove, dragAndDropEnabled]);
173
183
  // EVERYTHING downstream reads `view`, never `columns`: it is what is on
174
184
  // screen, so it is also what a drag, a keyboard move and a drop are about.
175
185
  // `columns` is only ever consulted to ask whether the host has caught up.
@@ -1,13 +1,14 @@
1
1
  <!-- source-hash: manual -->
2
2
  A vertical list whose items are reordered by dragging a handle, built on Pragmatic drag and drop.
3
+ **The one sortable-list implementation in the stack** — every ordered list (schedule scripts,
4
+ ticket statuses, AI quick actions via `TicketStatusConfigList`) runs on it; `@dnd-kit` is gone.
3
5
 
4
- > **Currently unused.** It was written for the ordered lists — schedule scripts, ticket statuses,
5
- > AI quick actions — and those have since gone back to `@dnd-kit` (`TicketStatusConfigList`), whose
6
- > pointer sensors work under touch where the HTML5 drag-and-drop API this is built on does not.
7
- > Kept, not deleted: it is a complete and general implementation, and the ticket board next door
8
- > still runs on the same library. Reach for `TicketStatusConfigList` for a new ordered list; reach
9
- > for this one only if you specifically need what it does differently — slot geometry with
10
- > hysteresis, a FLIP drop animation, and auto-scroll through every scrollable ancestor.
6
+ The HTML5 drag-and-drop API this is built on does not fire under touch — which is why those lists
7
+ once went back to `@dnd-kit`. The answer now is not a second library: on touch/narrow viewports
8
+ (`useDragAndDropEnabled()` is `false`) **no drag is initialized at all**, and each row renders an
9
+ explicit up/down `SortableMoveButtons` pair instead of its handle. Explicit buttons because on
10
+ touch nothing else survives contact with the page: a drag fights scrolling, long-press fights the
11
+ context menu.
11
12
 
12
13
  Deliberately **not** the ticket board, which moves cards between columns and needs a different
13
14
  model.
@@ -17,21 +18,26 @@ model.
17
18
  | Export | Description |
18
19
  |---|---|
19
20
  | `SortableList` | The container. Owns the geometry, the drag monitor and the live region |
20
- | `useSortableItem` | Registers one item: gives back `itemRef`, `dragHandleProps` and `isDragging` |
21
+ | `useSortableItem` | Registers one item: gives back `itemRef`, `dragHandleProps`, `isDragging` and `dragAndDropEnabled` |
22
+ | `SortableMoveButtons` | The touch fallback: a joined up/down pair, disabled at the ends, announcing through the list's live region |
21
23
  | `SLOT_STICKINESS` | The dead band around a slot boundary, as a share of one slot's travel |
22
24
 
23
25
  ## Usage
24
26
 
25
27
  ```tsx
26
28
  <SortableList onReorder={move} getItemLabel={i => rows[i].name} className="flex flex-col gap-6">
27
- {rows.map(row => <Row key={row.id} row={row} />)}
29
+ {rows.map((row, i) => <Row key={row.id} row={row} index={i} count={rows.length} />)}
28
30
  </SortableList>
29
31
 
30
- function Row({ row }) {
31
- const { itemRef, dragHandleProps, isDragging } = useSortableItem()
32
+ function Row({ row, index, count }) {
33
+ const { itemRef, dragHandleProps, isDragging, dragAndDropEnabled } = useSortableItem()
32
34
  return (
33
35
  <div ref={itemRef} className={isDragging ? 'border-ods-accent shadow-lg' : 'border-ods-border'}>
34
- <button type="button" {...dragHandleProps} aria-label="Reorder…"><DraggerIcon /></button>
36
+ {dragAndDropEnabled ? (
37
+ <button type="button" {...dragHandleProps} aria-label="Reorder…"><DraggerIcon /></button>
38
+ ) : (
39
+ <SortableMoveButtons index={index} count={count} label={row.name} />
40
+ )}
35
41
  …
36
42
  </div>
37
43
  )
@@ -76,6 +82,11 @@ function Row({ row }) {
76
82
  moves the list and the pointer together and nothing goes stale.
77
83
  - **Keyboard.** Pragmatic drag and drop ships none, so `dragHandleProps` carries Arrow Up / Arrow
78
84
  Down — one press, one move. The list announces every move through its own live region.
85
+ - **Input gating.** `useDragAndDropEnabled()` — `(pointer: fine) and (hover: hover) and
86
+ (min-width: 800px)` — decides whether any drag exists. `false` (touch, narrow windows, and SSR /
87
+ first render) skips every Pragmatic registration; `SortableMoveButtons` go through the same
88
+ `reorder` as a drop, so the announcement and the `onReorder` contract are identical on every
89
+ input. The consumer `disabled` prop still switches everything off, buttons included.
79
90
 
80
91
  ## Source
81
92
 
@@ -1,3 +1,4 @@
1
1
  export { SLOT_STICKINESS } from './slot-geometry';
2
2
  export { SortableList, type SortableListProps } from './sortable-list';
3
- export { type SortableItem, useSortableItem } from './use-sortable-item';
3
+ export { SortableMoveButtons, type SortableMoveButtonsProps } from './sortable-move-buttons';
4
+ export { type SortableDragHandleProps, type SortableItem, useSortableItem } from './use-sortable-item';
@@ -14,6 +14,7 @@ import {
14
14
  useRef,
15
15
  useState,
16
16
  } from 'react';
17
+ import { useDragAndDropEnabled } from '../../../hooks/ui/use-drag-and-drop-enabled';
17
18
  import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect';
18
19
  import { autoScrollAncestors, autoScrollBothWays } from '../../../utils/auto-scroll-ancestors';
19
20
  import { type DragSlots, measureSlots, resolveSlot, shiftFor } from './slot-geometry';
@@ -30,8 +31,15 @@ const EASING = 'cubic-bezier(0.2, 0, 0, 1)';
30
31
  interface SortableListContextValue {
31
32
  /** Distinguishes this list's drags from any other list's on the page. */
32
33
  dragType: string;
34
+ /** The consumer's `disabled` prop: everything off, move buttons included. */
33
35
  disabled: boolean;
34
- /** Reorders the list. Used by the drop AND by a handle's arrow keys. */
36
+ /**
37
+ * The input-type gate (`useDragAndDropEnabled`): `false` on touch/narrow
38
+ * viewports, where no drag is registered and rows render explicit
39
+ * `SortableMoveButtons` instead of a handle.
40
+ */
41
+ dragAndDropEnabled: boolean;
42
+ /** Reorders the list. Used by the drop, a handle's arrow keys AND the move buttons. */
35
43
  reorder: (from: number, to: number) => void;
36
44
  /** Every registered item, in DOM order. */
37
45
  readItems: () => HTMLElement[];
@@ -82,6 +90,9 @@ export interface SortableListProps {
82
90
  */
83
91
  export function SortableList({ onReorder, getItemLabel, disabled = false, className, children }: SortableListProps) {
84
92
  const listRef = useRef<HTMLDivElement>(null);
93
+ // False on touch/narrow viewports: no drag is initialized anywhere in the
94
+ // list, and rows fall back to explicit move buttons.
95
+ const dragAndDropEnabled = useDragAndDropEnabled();
85
96
  // Stable across renders and never rendered, so there is no hydration to
86
97
  // mismatch — unlike keying a drag on a random per-item id.
87
98
  const dragType = useId();
@@ -126,7 +137,7 @@ export function SortableList({ onReorder, getItemLabel, disabled = false, classN
126
137
 
127
138
  useEffect(() => {
128
139
  const list = listRef.current;
129
- if (!list) return undefined;
140
+ if (!list || disabled || !dragAndDropEnabled) return undefined;
130
141
 
131
142
  /** Alive for one drag; this effect body is its whole scope. */
132
143
  let drag: (DragSlots & { items: HTMLElement[]; to: number; startY: number }) | null = null;
@@ -218,7 +229,7 @@ export function SortableList({ onReorder, getItemLabel, disabled = false, classN
218
229
  },
219
230
  }),
220
231
  );
221
- }, [dragType, readItems, reorder]);
232
+ }, [dragType, disabled, dragAndDropEnabled, readItems, reorder]);
222
233
 
223
234
  // Runs after the commit that carries BOTH the reorder and the tick above, so
224
235
  // the DOM is already in its final order — which is why `onReorder` never has
@@ -267,8 +278,8 @@ export function SortableList({ onReorder, getItemLabel, disabled = false, classN
267
278
  );
268
279
 
269
280
  const context = useMemo<SortableListContextValue>(
270
- () => ({ dragType, disabled, reorder, readItems }),
271
- [dragType, disabled, reorder, readItems],
281
+ () => ({ dragType, disabled, dragAndDropEnabled, reorder, readItems }),
282
+ [dragType, disabled, dragAndDropEnabled, reorder, readItems],
272
283
  );
273
284
 
274
285
  return (
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ import { useRef } from 'react';
4
+ import { cn } from '../../../utils/cn';
5
+ import { Arrow01DownIcon } from '../../icons-v2-generated/arrows/arrow-01-down-icon';
6
+ import { Arrow01UpIcon } from '../../icons-v2-generated/arrows/arrow-01-up-icon';
7
+ import { Button } from '../../ui/button';
8
+ import { useSortableListContext } from './sortable-list';
9
+
10
+ export interface SortableMoveButtonsProps {
11
+ /** The row's current position — the caller renders the rows, so it knows. */
12
+ index: number;
13
+ /** How many rows the list has; the down button is disabled on the last one. */
14
+ count: number;
15
+ /** Names the row in the buttons' accessible labels, e.g. "On Hold". */
16
+ label?: string;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * The touch replacement for a drag handle: an up/down pair rendered by a row of
22
+ * a {@link SortableList} when `dragAndDropEnabled` is `false` (see
23
+ * `useSortableItem`). Explicit buttons because on touch nothing else survives
24
+ * contact with the page: a drag fights scrolling, long-press fights the context
25
+ * menu, and the HTML5 drag events behind Pragmatic never fire at all.
26
+ *
27
+ * Moves go through the list's `reorder`, so they hit the same `onReorder`
28
+ * callback and the same live region as a drop or a handle's arrow keys.
29
+ *
30
+ * One joined pair per the design: outer corners rounded, a single hairline
31
+ * between the halves, each half disabled at its end of the list.
32
+ */
33
+ export function SortableMoveButtons({ index, count, label, className }: SortableMoveButtonsProps) {
34
+ const { disabled, reorder } = useSortableListContext();
35
+ const upRef = useRef<HTMLButtonElement>(null);
36
+ const downRef = useRef<HTMLButtonElement>(null);
37
+
38
+ const move = (step: -1 | 1) => {
39
+ const to = index + step;
40
+ if (to < 0 || to >= count) return;
41
+ reorder(index, to);
42
+ // The row this pair sits in keeps its DOM nodes, but the pressed half may
43
+ // come back disabled (the row reached an end) — hand focus to the other
44
+ // half so a keyboard user is not dropped to the page body.
45
+ requestAnimationFrame(() => {
46
+ if (step === -1 && to === 0) downRef.current?.focus();
47
+ if (step === 1 && to === count - 1) upRef.current?.focus();
48
+ });
49
+ };
50
+
51
+ const subject = label ?? 'item';
52
+
53
+ return (
54
+ <div className={cn('inline-flex shrink-0', className)}>
55
+ <Button
56
+ ref={upRef}
57
+ type="button"
58
+ variant="outline"
59
+ size="icon"
60
+ aria-label={`Move ${subject} up`}
61
+ disabled={disabled || index <= 0}
62
+ onClick={() => move(-1)}
63
+ className="rounded-r-none border-r-0"
64
+ >
65
+ <Arrow01UpIcon />
66
+ </Button>
67
+ <Button
68
+ ref={downRef}
69
+ type="button"
70
+ variant="outline"
71
+ size="icon"
72
+ aria-label={`Move ${subject} down`}
73
+ disabled={disabled || index >= count - 1}
74
+ onClick={() => move(1)}
75
+ className="rounded-l-none"
76
+ >
77
+ <Arrow01DownIcon />
78
+ </Button>
79
+ </div>
80
+ );
81
+ }
@@ -15,14 +15,23 @@ export interface SortableItem {
15
15
  * clicking inside the item keep working, and it carries the keyboard
16
16
  * alternative — so spread it rather than picking it apart.
17
17
  */
18
- dragHandleProps: {
19
- // Callback refs, not `Ref<HTMLElement>`: a ref OBJECT of the wider type
20
- // cannot be attached to a `<button>` or a `<div>`, a callback taking the
21
- // wider type can.
22
- ref: (node: HTMLElement | null) => void;
23
- onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
24
- };
18
+ dragHandleProps: SortableDragHandleProps;
25
19
  isDragging: boolean;
20
+ /**
21
+ * The input-type gate: `false` on touch/narrow viewports (and during SSR).
22
+ * When `false` the item registers no drag at all — render explicit move
23
+ * controls (`SortableMoveButtons`) instead of the drag handle.
24
+ */
25
+ dragAndDropEnabled: boolean;
26
+ }
27
+
28
+ /** What `dragHandleProps` carries — spread it onto the grip, don't pick it apart. */
29
+ export interface SortableDragHandleProps {
30
+ // Callback refs, not `Ref<HTMLElement>`: a ref OBJECT of the wider type
31
+ // cannot be attached to a `<button>` or a `<div>`, a callback taking the
32
+ // wider type can.
33
+ ref: (node: HTMLElement | null) => void;
34
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
26
35
  }
27
36
 
28
37
  /**
@@ -38,7 +47,10 @@ export interface SortableItem {
38
47
  * screen-reader users, who hear the result from the list's live region.
39
48
  */
40
49
  export function useSortableItem(): SortableItem {
41
- const { dragType, disabled, reorder, readItems } = useSortableListContext();
50
+ const { dragType, disabled, dragAndDropEnabled, reorder, readItems } = useSortableListContext();
51
+ // Both stop the drag registration and the keyboard path; only the consumer's
52
+ // `disabled` also stops the move buttons, which are the touch replacement.
53
+ const inert = disabled || !dragAndDropEnabled;
42
54
  // State rather than refs: registration has to wait until the consumer has
43
55
  // actually rendered its grip, and only a re-render tells us it did.
44
56
  const [item, setItem] = useState<HTMLElement | null>(null);
@@ -54,7 +66,7 @@ export function useSortableItem(): SortableItem {
54
66
  }, []);
55
67
 
56
68
  useEffect(() => {
57
- if (!item || !handle || disabled) return undefined;
69
+ if (!item || !handle || inert) return undefined;
58
70
  return combine(
59
71
  // The drag has to start as a "move", or its first frame is drawn with the
60
72
  // copy cursor — see `drag-effect.ts`.
@@ -69,12 +81,12 @@ export function useSortableItem(): SortableItem {
69
81
  onDrop: () => setIsDragging(false),
70
82
  }),
71
83
  );
72
- }, [item, handle, disabled, dragType]);
84
+ }, [item, handle, inert, dragType]);
73
85
 
74
86
  const onKeyDown = useCallback(
75
87
  (event: KeyboardEvent<HTMLElement>) => {
76
88
  const step = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0;
77
- if (!step || disabled) return;
89
+ if (!step || inert) return;
78
90
  const items = readItems();
79
91
  const from = items.indexOf(event.currentTarget.closest(`[${SORTABLE_ITEM_ATTRIBUTE}]`) as HTMLElement);
80
92
  const to = from + step;
@@ -85,8 +97,8 @@ export function useSortableItem(): SortableItem {
85
97
  // run of presses keeps moving the same item.
86
98
  requestAnimationFrame(() => handle?.focus());
87
99
  },
88
- [disabled, readItems, reorder, handle],
100
+ [inert, readItems, reorder, handle],
89
101
  );
90
102
 
91
- return { itemRef, dragHandleProps: { ref: setHandle, onKeyDown }, isDragging };
103
+ return { itemRef, dragHandleProps: { ref: setHandle, onKeyDown }, isDragging, dragAndDropEnabled };
92
104
  }
@@ -1,94 +1,87 @@
1
1
  'use client';
2
2
 
3
- import {
4
- DndContext,
5
- type DragEndEvent,
6
- type DraggableAttributes,
7
- type DraggableSyntheticListeners,
8
- KeyboardSensor,
9
- PointerSensor,
10
- closestCenter,
11
- useSensor,
12
- useSensors,
13
- } from '@dnd-kit/core';
14
- import {
15
- SortableContext,
16
- sortableKeyboardCoordinates,
17
- useSortable,
18
- verticalListSortingStrategy,
19
- } from '@dnd-kit/sortable';
20
- import { CSS } from '@dnd-kit/utilities';
21
- import { type CSSProperties, type ReactNode, useMemo } from 'react';
3
+ import type { ReactNode } from 'react';
22
4
  import { cn } from '../../../utils/cn';
5
+ import { SortableList } from '../sortable-list/sortable-list';
6
+ import { SortableMoveButtons } from '../sortable-list/sortable-move-buttons';
7
+ import { type SortableDragHandleProps, useSortableItem } from '../sortable-list/use-sortable-item';
23
8
 
24
9
  export interface SortableRowRenderArgs {
25
- dragHandleProps: DraggableSyntheticListeners;
26
- dragHandleAttributes: DraggableAttributes;
10
+ /** Spread onto the grip whole — it carries the ref and the keyboard path. */
11
+ dragHandleProps: SortableDragHandleProps;
27
12
  isDragging: boolean;
13
+ /**
14
+ * The touch replacement for the grip: an up/down `SortableMoveButtons` pair,
15
+ * non-null exactly when drag is unavailable (touch/narrow viewports). Render
16
+ * it where the row's reorder control belongs — and no grip beside it.
17
+ */
18
+ moveButtons: ReactNode | null;
28
19
  }
29
20
 
30
21
  export interface TicketStatusConfigListProps<T extends { id: string }> {
31
22
  items: T[];
32
23
  onReorder: (oldIndex: number, newIndex: number) => void;
33
24
  renderRow: (item: T, args: SortableRowRenderArgs) => ReactNode;
25
+ /** Names a row for the live region and the move buttons, e.g. its status name. */
26
+ getItemLabel?: (index: number) => string | undefined;
34
27
  className?: string;
35
28
  }
36
29
 
30
+ /**
31
+ * A reorderable list of config rows (ticket statuses, AI quick actions), built
32
+ * on {@link SortableList} — Pragmatic drag and drop on desktop, an explicit
33
+ * up/down pair on touch, where the HTML5 drag events never fire.
34
+ *
35
+ * `id` keys React rows only; the reorder itself is DOM-order based, so
36
+ * duplicates in `items` cannot collide.
37
+ */
37
38
  export function TicketStatusConfigList<T extends { id: string }>({
38
39
  items,
39
40
  onReorder,
40
41
  renderRow,
42
+ getItemLabel,
41
43
  className,
42
44
  }: TicketStatusConfigListProps<T>) {
43
- const sensors = useSensors(
44
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
45
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
46
- );
47
-
48
- const handleDragEnd = (event: DragEndEvent) => {
49
- const { active, over } = event;
50
- if (!over || active.id === over.id) return;
51
- const oldIndex = items.findIndex(i => i.id === active.id);
52
- const newIndex = items.findIndex(i => i.id === over.id);
53
- if (oldIndex === -1 || newIndex === -1) return;
54
- onReorder(oldIndex, newIndex);
55
- };
56
-
57
- const sortableIds = useMemo(() => items.map(i => i.id), [items]);
58
-
59
45
  return (
60
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
61
- <SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
62
- <div className={cn('flex w-full flex-col gap-[var(--spacing-system-xs)]', className)}>
63
- {items.map(item => (
64
- <SortableRow key={item.id} id={item.id}>
65
- {args => renderRow(item, args)}
66
- </SortableRow>
67
- ))}
68
- </div>
69
- </SortableContext>
70
- </DndContext>
46
+ <SortableList
47
+ onReorder={onReorder}
48
+ getItemLabel={getItemLabel}
49
+ className={cn('flex w-full flex-col gap-[var(--spacing-system-xs)]', className)}
50
+ >
51
+ {items.map((item, index) => (
52
+ <SortableRow key={item.id} index={index} count={items.length} label={getItemLabel?.(index)}>
53
+ {args => renderRow(item, args)}
54
+ </SortableRow>
55
+ ))}
56
+ </SortableList>
71
57
  );
72
58
  }
73
59
 
74
- function SortableRow({ id, children }: { id: string; children: (args: SortableRowRenderArgs) => ReactNode }) {
75
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
76
-
77
- const style: CSSProperties = {
78
- transform: CSS.Transform.toString(transform),
79
- transition,
80
- // Raise the actively dragged row above its siblings — without this the
81
- // stacking follows DOM order, so a row dragged DOWN slides UNDER the rows
82
- // below it while a row dragged up correctly renders on top.
83
- zIndex: isDragging ? 1 : undefined,
84
- };
60
+ function SortableRow({
61
+ index,
62
+ count,
63
+ label,
64
+ children,
65
+ }: {
66
+ index: number;
67
+ count: number;
68
+ label?: string;
69
+ children: (args: SortableRowRenderArgs) => ReactNode;
70
+ }) {
71
+ const { itemRef, dragHandleProps, isDragging, dragAndDropEnabled } = useSortableItem();
85
72
 
86
73
  return (
87
- <div ref={setNodeRef} style={style}>
74
+ <div
75
+ ref={itemRef}
76
+ // Raise the actively dragged row above its siblings — without this the
77
+ // stacking follows DOM order, so a row dragged DOWN slides UNDER the rows
78
+ // below it while a row dragged up correctly renders on top.
79
+ className={isDragging ? 'relative z-10' : undefined}
80
+ >
88
81
  {children({
89
- dragHandleProps: listeners,
90
- dragHandleAttributes: attributes,
82
+ dragHandleProps,
91
83
  isDragging,
84
+ moveButtons: dragAndDropEnabled ? null : <SortableMoveButtons index={index} count={count} label={label} />,
92
85
  })}
93
86
  </div>
94
87
  );