@flamingo-stack/openframe-frontend-core 0.0.579-1952.5335.1 → 0.0.579
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.
- package/dist/chat-protocol/decode.d.ts.map +1 -1
- package/dist/chat-protocol/events.d.ts +47 -1
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +51 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +48 -1
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/leading-frames.d.ts +9 -3
- package/dist/chat-protocol/leading-frames.d.ts.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts +41 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-3B42TFEE.cjs → chunk-36TJYCF3.cjs} +27 -27
- package/dist/{chunk-3B42TFEE.cjs.map → chunk-36TJYCF3.cjs.map} +1 -1
- package/dist/{chunk-YYEVFC3K.cjs → chunk-37XJYOAY.cjs} +17 -17
- package/dist/{chunk-YYEVFC3K.cjs.map → chunk-37XJYOAY.cjs.map} +1 -1
- package/dist/{chunk-DBP7S75Q.js → chunk-3AX3ZEY3.js} +1481 -1318
- package/dist/chunk-3AX3ZEY3.js.map +1 -0
- package/dist/{chunk-DGB2FLFH.cjs → chunk-4FJSN2XW.cjs} +10 -10
- package/dist/{chunk-DGB2FLFH.cjs.map → chunk-4FJSN2XW.cjs.map} +1 -1
- package/dist/{chunk-WHSSI3S7.cjs → chunk-4HGRXFQP.cjs} +3 -3
- package/dist/{chunk-WHSSI3S7.cjs.map → chunk-4HGRXFQP.cjs.map} +1 -1
- package/dist/{chunk-SY5HCZZO.cjs → chunk-4LZ7B5H5.cjs} +5 -5
- package/dist/{chunk-SY5HCZZO.cjs.map → chunk-4LZ7B5H5.cjs.map} +1 -1
- package/dist/{chunk-XJ45MHWI.js → chunk-4WSKSNXL.js} +9 -9
- package/dist/{chunk-ZCEQLHU3.js → chunk-5Y6TPFW7.js} +5 -5
- package/dist/{chunk-EVTRRDIH.cjs → chunk-A6C5HOD7.cjs} +23 -23
- package/dist/{chunk-EVTRRDIH.cjs.map → chunk-A6C5HOD7.cjs.map} +1 -1
- package/dist/{chunk-RXZWUJEB.js → chunk-AKPLEWMH.js} +2 -2
- package/dist/{chunk-7WKFLOC7.cjs → chunk-BCJQ7CJE.cjs} +11 -11
- package/dist/{chunk-7WKFLOC7.cjs.map → chunk-BCJQ7CJE.cjs.map} +1 -1
- package/dist/{chunk-C4KL7OQI.js → chunk-BW4WDUSI.js} +3 -3
- package/dist/{chunk-L66XBQM6.js → chunk-CP5EYMNL.js} +276 -160
- package/dist/chunk-CP5EYMNL.js.map +1 -0
- package/dist/{chunk-QOPQ6BCA.js → chunk-D44S2QLI.js} +3 -3
- package/dist/{chunk-CGVPUA4X.js → chunk-DB3TV4CW.js} +6 -6
- package/dist/{chunk-PITZMTGE.cjs → chunk-FEVLXRTC.cjs} +506 -390
- package/dist/chunk-FEVLXRTC.cjs.map +1 -0
- package/dist/{chunk-TYJITV3H.js → chunk-H4BPHGU5.js} +3 -3
- package/dist/{chunk-C7Q2PVHD.cjs → chunk-H54EPP3P.cjs} +7 -7
- package/dist/{chunk-C7Q2PVHD.cjs.map → chunk-H54EPP3P.cjs.map} +1 -1
- package/dist/{chunk-NWAUHBYH.js → chunk-HGA74X35.js} +4 -4
- package/dist/{chunk-4C3MS2G6.cjs → chunk-HHJVIHVR.cjs} +81 -81
- package/dist/{chunk-4C3MS2G6.cjs.map → chunk-HHJVIHVR.cjs.map} +1 -1
- package/dist/{chunk-3XFMM34I.cjs → chunk-IFYQ7DHV.cjs} +48 -48
- package/dist/{chunk-3XFMM34I.cjs.map → chunk-IFYQ7DHV.cjs.map} +1 -1
- package/dist/{chunk-QYQXAXVR.cjs → chunk-IKDC3R7V.cjs} +4 -3
- package/dist/chunk-IKDC3R7V.cjs.map +1 -0
- package/dist/{chunk-W6JA6SFD.js → chunk-JRYS4VMV.js} +45 -45
- package/dist/chunk-JRYS4VMV.js.map +1 -0
- package/dist/{chunk-AT4WAFJO.js → chunk-L2CF2QZW.js} +7 -7
- package/dist/{chunk-2BFZHTSD.cjs → chunk-LEISUAHF.cjs} +18 -8
- package/dist/chunk-LEISUAHF.cjs.map +1 -0
- package/dist/{chunk-AAHKPPGQ.cjs → chunk-M5GWCMYE.cjs} +42 -42
- package/dist/{chunk-AAHKPPGQ.cjs.map → chunk-M5GWCMYE.cjs.map} +1 -1
- package/dist/{chunk-PQETCTJ4.js → chunk-MXOSTJAG.js} +4 -3
- package/dist/chunk-MXOSTJAG.js.map +1 -0
- package/dist/{chunk-653A7FPP.cjs → chunk-NPVDKOGD.cjs} +22 -22
- package/dist/{chunk-653A7FPP.cjs.map → chunk-NPVDKOGD.cjs.map} +1 -1
- package/dist/{chunk-2ML5QEDH.js → chunk-O2FTVZOD.js} +5 -5
- package/dist/{chunk-OCSAB6NT.cjs → chunk-OGZAFM6M.cjs} +5 -5
- package/dist/{chunk-OCSAB6NT.cjs.map → chunk-OGZAFM6M.cjs.map} +1 -1
- package/dist/{chunk-CSBNQJSI.js → chunk-PMENIZXI.js} +6 -6
- package/dist/{chunk-PFTEG6L7.js → chunk-PWOYEUIQ.js} +7 -7
- package/dist/{chunk-KXLJT5U5.js → chunk-QCP2KV7P.js} +5 -5
- package/dist/{chunk-6QDW4CDG.js → chunk-QGJYWLF4.js} +2 -2
- package/dist/{chunk-VLRXLTR4.cjs → chunk-RKGOVTXZ.cjs} +377 -214
- package/dist/chunk-RKGOVTXZ.cjs.map +1 -0
- package/dist/{chunk-HM2R5MDR.js → chunk-T5HDPHN6.js} +13 -3
- package/dist/chunk-T5HDPHN6.js.map +1 -0
- package/dist/{chunk-GP2QLZIN.cjs → chunk-YW4WBNYV.cjs} +37 -37
- package/dist/{chunk-GP2QLZIN.cjs.map → chunk-YW4WBNYV.cjs.map} +1 -1
- package/dist/{chunk-36NFJULV.cjs → chunk-ZI27ALYX.cjs} +41 -41
- package/dist/chunk-ZI27ALYX.cjs.map +1 -0
- package/dist/components/case-studies/index.cjs +12 -12
- package/dist/components/case-studies/index.js +6 -6
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/guide-display.d.ts +14 -0
- package/dist/components/chat/guide-display.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +11 -3
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +10 -2
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +9 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/stream/delta-batcher.d.ts +3 -3
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -1
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +6 -0
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +30 -2
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +3 -1
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +4 -1
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/approval-display.d.ts +36 -8
- package/dist/components/chat/utils/approval-display.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +1 -1
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +16 -4
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +1 -1
- package/dist/components/contact/index.cjs +7 -7
- package/dist/components/contact/index.js +6 -6
- package/dist/components/docs/index.cjs +10 -10
- package/dist/components/docs/index.js +9 -9
- package/dist/components/embeds/index.cjs +7 -7
- package/dist/components/embeds/index.js +6 -6
- package/dist/components/faq/index.cjs +7 -7
- package/dist/components/faq/index.js +6 -6
- package/dist/components/features/board/board.d.ts.map +1 -1
- package/dist/components/features/index.cjs +8 -6
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +7 -5
- package/dist/components/features/sortable-list/index.d.ts +2 -1
- package/dist/components/features/sortable-list/index.d.ts.map +1 -1
- package/dist/components/features/sortable-list/sortable-list.d.ts +8 -1
- package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -1
- package/dist/components/features/sortable-list/sortable-move-buttons.d.ts +24 -0
- package/dist/components/features/sortable-list/sortable-move-buttons.d.ts.map +1 -0
- package/dist/components/features/sortable-list/use-sortable-item.d.ts +12 -4
- package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -1
- package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts +21 -5
- package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +26 -26
- package/dist/components/help-center-pages/index.js +17 -17
- package/dist/components/index.cjs +139 -129
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +26 -16
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +37 -37
- package/dist/components/meeting-scheduler/index.js +5 -5
- package/dist/components/navigation/index.cjs +9 -9
- package/dist/components/navigation/index.js +8 -8
- package/dist/components/onboarding-guides/index.cjs +9 -9
- package/dist/components/onboarding-guides/index.js +8 -8
- package/dist/components/tickets/index.cjs +10 -10
- package/dist/components/tickets/index.js +9 -9
- package/dist/components/ui/file-manager/index.cjs +4 -4
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +14 -6
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +13 -5
- package/dist/components/ui/input.d.ts +2 -0
- package/dist/components/ui/input.d.ts.map +1 -1
- package/dist/components/ui/textarea.d.ts +2 -0
- package/dist/components/ui/textarea.d.ts.map +1 -1
- package/dist/components/ui/ticket-status-config-row.d.ts +10 -4
- package/dist/components/ui/ticket-status-config-row.d.ts.map +1 -1
- package/dist/hooks/index.cjs +7 -3
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +6 -2
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts +31 -0
- package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts.map +1 -0
- package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
- package/dist/index.cjs +25 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +23 -9
- package/dist/index.js.map +1 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -4
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +80 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +118 -1
- package/src/chat-protocol/decode.ts +4 -3
- package/src/chat-protocol/events.ts +51 -0
- package/src/chat-protocol/leading-frames.ts +9 -3
- package/src/chat-protocol/nats-decoder.ts +110 -1
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +19 -0
- package/src/components/chat/chat-message-enhanced.tsx +13 -1
- package/src/components/chat/guide-display.tsx +42 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +90 -0
- package/src/components/chat/stream/chat-dialog-store.ts +1 -1
- package/src/components/chat/stream/chat-stream-reducer.ts +49 -11
- package/src/components/chat/stream/delta-batcher.ts +9 -4
- package/src/components/chat/stream/message-mutations.ts +2 -0
- package/src/components/chat/types/component.types.ts +9 -0
- package/src/components/chat/types/message.types.ts +32 -0
- package/src/components/chat/types/network.types.ts +3 -1
- package/src/components/chat/types/processing.types.ts +4 -1
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +93 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +78 -1
- package/src/components/chat/utils/approval-display.ts +53 -8
- package/src/components/chat/utils/extract-incomplete-message-state.ts +1 -0
- package/src/components/chat/utils/index.ts +6 -1
- package/src/components/chat/utils/message-segment-accumulator.ts +37 -3
- package/src/components/chat/utils/process-historical-messages.ts +44 -7
- package/src/components/chat/utils/resolve-fetched-card-href.ts +1 -1
- package/src/components/features/board/.board.md +1 -0
- package/src/components/features/board/board.tsx +11 -1
- package/src/components/features/sortable-list/.sortable-list.md +23 -12
- package/src/components/features/sortable-list/index.ts +2 -1
- package/src/components/features/sortable-list/sortable-list.tsx +16 -5
- package/src/components/features/sortable-list/sortable-move-buttons.tsx +81 -0
- package/src/components/features/sortable-list/use-sortable-item.ts +25 -13
- package/src/components/features/ticket-status-config-list/ticket-status-config-list.tsx +55 -62
- package/src/components/ui/.ticket-status-config-row.md +11 -9
- package/src/components/ui/input.tsx +5 -2
- package/src/components/ui/textarea.tsx +5 -2
- package/src/components/ui/ticket-status-config-row.tsx +162 -70
- package/src/hooks/ui/.index.md +1 -0
- package/src/hooks/ui/.use-drag-and-drop-enabled.md +32 -0
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-drag-and-drop-enabled.ts +37 -0
- package/src/hooks/ui/use-media-query.ts +3 -2
- package/src/stories/ChatMessageEnhanced.stories.tsx +7 -3
- package/dist/chunk-2BFZHTSD.cjs.map +0 -1
- package/dist/chunk-36NFJULV.cjs.map +0 -1
- package/dist/chunk-DBP7S75Q.js.map +0 -1
- package/dist/chunk-HM2R5MDR.js.map +0 -1
- package/dist/chunk-L66XBQM6.js.map +0 -1
- package/dist/chunk-PITZMTGE.cjs.map +0 -1
- package/dist/chunk-PQETCTJ4.js.map +0 -1
- package/dist/chunk-QYQXAXVR.cjs.map +0 -1
- package/dist/chunk-VLRXLTR4.cjs.map +0 -1
- package/dist/chunk-W6JA6SFD.js.map +0 -1
- /package/dist/{chunk-XJ45MHWI.js.map → chunk-4WSKSNXL.js.map} +0 -0
- /package/dist/{chunk-ZCEQLHU3.js.map → chunk-5Y6TPFW7.js.map} +0 -0
- /package/dist/{chunk-RXZWUJEB.js.map → chunk-AKPLEWMH.js.map} +0 -0
- /package/dist/{chunk-C4KL7OQI.js.map → chunk-BW4WDUSI.js.map} +0 -0
- /package/dist/{chunk-QOPQ6BCA.js.map → chunk-D44S2QLI.js.map} +0 -0
- /package/dist/{chunk-CGVPUA4X.js.map → chunk-DB3TV4CW.js.map} +0 -0
- /package/dist/{chunk-TYJITV3H.js.map → chunk-H4BPHGU5.js.map} +0 -0
- /package/dist/{chunk-NWAUHBYH.js.map → chunk-HGA74X35.js.map} +0 -0
- /package/dist/{chunk-AT4WAFJO.js.map → chunk-L2CF2QZW.js.map} +0 -0
- /package/dist/{chunk-2ML5QEDH.js.map → chunk-O2FTVZOD.js.map} +0 -0
- /package/dist/{chunk-CSBNQJSI.js.map → chunk-PMENIZXI.js.map} +0 -0
- /package/dist/{chunk-PFTEG6L7.js.map → chunk-PWOYEUIQ.js.map} +0 -0
- /package/dist/{chunk-KXLJT5U5.js.map → chunk-QCP2KV7P.js.map} +0 -0
- /package/dist/{chunk-6QDW4CDG.js.map → chunk-QGJYWLF4.js.map} +0 -0
|
@@ -4,20 +4,65 @@
|
|
|
4
4
|
*
|
|
5
5
|
* There are three such paths and they must agree: the NATS kernel (live chunks),
|
|
6
6
|
* the SSE kernel, and the history replay that rebuilds a dialog after a reload.
|
|
7
|
-
* When the rule lived only in the live kernel, a
|
|
8
|
-
* streaming and then
|
|
9
|
-
* same card, two behaviors
|
|
7
|
+
* When the rule lived only in the live kernel, a Product Guide card rendered
|
|
8
|
+
* inline while streaming and then moved into the escalation treatment on the
|
|
9
|
+
* next page load — same card, two behaviors, and its buttons pointed at the
|
|
10
|
+
* wrong backend once it moved.
|
|
11
|
+
*
|
|
12
|
+
* The rule itself: a card is gated on approval TIER (`displayApprovalTypes`,
|
|
13
|
+
* the agent's own privilege model) UNLESS it came from the Product Guide, whose
|
|
14
|
+
* proposals are typed by TOOL and are resolved through the hub's confirm route.
|
|
15
|
+
* A hub proposal has no tier to gate on, so tier-gating one means escalating it
|
|
16
|
+
* to a human who has no way to act on it.
|
|
10
17
|
*/
|
|
11
18
|
|
|
19
|
+
import type { GuideOrigin } from '../../../chat-protocol/events';
|
|
20
|
+
import { GUIDE_ORIGIN, isGuideOrigin } from '../../../chat-protocol/events';
|
|
21
|
+
import type { MessageSegment } from '../types/message.types';
|
|
22
|
+
|
|
23
|
+
/** Anything carrying the optional `origin` marker: a stream event, or the
|
|
24
|
+
* `data` of a rendered segment. */
|
|
25
|
+
interface OriginBearing {
|
|
26
|
+
origin?: GuideOrigin | string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when this approval was minted by the Product Guide (the hub) rather
|
|
30
|
+
* than by the agent's own tool-approval tiering. */
|
|
31
|
+
export function isGuideApproval(source: OriginBearing | null | undefined): boolean {
|
|
32
|
+
return isGuideOrigin(source);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The marker to stamp onto a built segment, or undefined for agent approvals.
|
|
36
|
+
* Keeps `origin` out of segment data unless it means something. */
|
|
37
|
+
export function guideApprovalOrigin(source: OriginBearing | null | undefined): GuideOrigin | undefined {
|
|
38
|
+
return isGuideApproval(source) ? GUIDE_ORIGIN : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
12
41
|
/**
|
|
13
42
|
* Does this approval render inline in its turn (true), or take the consumer's
|
|
14
43
|
* escalation treatment (false)?
|
|
15
44
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* so both kernels can share one predicate.
|
|
45
|
+
* `displayApprovalTypes` undefined means "no tier gate configured" and every
|
|
46
|
+
* agent approval displays — the pre-existing history-replay default, preserved
|
|
47
|
+
* here so both kernels can share one predicate.
|
|
20
48
|
*/
|
|
21
|
-
export function approvalDisplaysInline(
|
|
49
|
+
export function approvalDisplaysInline(
|
|
50
|
+
source: OriginBearing | null | undefined,
|
|
51
|
+
approvalType: string,
|
|
52
|
+
displayApprovalTypes?: readonly string[],
|
|
53
|
+
): boolean {
|
|
54
|
+
if (isGuideApproval(source)) return true;
|
|
22
55
|
return !displayApprovalTypes || displayApprovalTypes.includes(approvalType);
|
|
23
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True for a rendered segment that holds a Product Guide card (single or batch).
|
|
60
|
+
*
|
|
61
|
+
* Consumers use it to keep such a card in the message flow, where the hub's own
|
|
62
|
+
* chat puts it, instead of lifting it into their sticky pending-approvals
|
|
63
|
+
* footer — and to route its buttons to the hub rather than to the agent.
|
|
64
|
+
*/
|
|
65
|
+
export function isGuideApprovalSegment(segment: MessageSegment): boolean {
|
|
66
|
+
if (segment.type !== 'approval_request' && segment.type !== 'approval_batch') return false;
|
|
67
|
+
return isGuideApproval(segment.data);
|
|
68
|
+
}
|
|
@@ -16,7 +16,12 @@ export {
|
|
|
16
16
|
|
|
17
17
|
// Where an approval card renders — one rule for both live kernels, the history
|
|
18
18
|
// replay, and hosts that sort cards between their own footer and the flow.
|
|
19
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
isGuideApproval,
|
|
21
|
+
guideApprovalOrigin,
|
|
22
|
+
approvalDisplaysInline,
|
|
23
|
+
isGuideApprovalSegment,
|
|
24
|
+
} from './approval-display';
|
|
20
25
|
|
|
21
26
|
// Historical message processing
|
|
22
27
|
export {
|
|
@@ -155,6 +155,22 @@ export class MessageSegmentAccumulator {
|
|
|
155
155
|
return this.getSegments();
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Append guide text to the current message.
|
|
160
|
+
* If the last segment is guide, append to it; otherwise start a new guide segment.
|
|
161
|
+
*/
|
|
162
|
+
appendGuide(text: string): MessageSegment[] {
|
|
163
|
+
const lastSegment = this.segments[this.segments.length - 1];
|
|
164
|
+
|
|
165
|
+
if (lastSegment && lastSegment.type === 'guide') {
|
|
166
|
+
this.segments[this.segments.length - 1] = { type: 'guide', text: lastSegment.text + text };
|
|
167
|
+
} else {
|
|
168
|
+
this.segments.push({ type: 'guide', text });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return this.getSegments();
|
|
172
|
+
}
|
|
173
|
+
|
|
158
174
|
/**
|
|
159
175
|
* Add a clarification (ask) card. Unlike the three delta streams an ask
|
|
160
176
|
* arrives whole in one chunk, so it is always a NEW segment — never merged
|
|
@@ -309,11 +325,15 @@ export class MessageSegmentAccumulator {
|
|
|
309
325
|
status: ChatApprovalStatus = 'pending',
|
|
310
326
|
/** Structured label/value rows. The card prefers them over `explanation`
|
|
311
327
|
* (see `ApprovalRequestData.fields`). Optional because the agent's own
|
|
312
|
-
* approvals carry prose
|
|
328
|
+
* approvals carry prose; a Product Guide card is almost entirely fields —
|
|
329
|
+
* dropping them here left it as a bare title. */
|
|
313
330
|
fields?: ApprovalRequestField[],
|
|
331
|
+
/** Where the card came from; `'guide'` keeps it inline (see
|
|
332
|
+
* `ApprovalRequestData.origin`). */
|
|
333
|
+
origin?: 'guide',
|
|
314
334
|
): MessageSegment[] {
|
|
315
335
|
this.segments.push(
|
|
316
|
-
this.buildApprovalRequestSegment(requestId, { command, explanation, approvalType, fields }, status),
|
|
336
|
+
this.buildApprovalRequestSegment(requestId, { command, explanation, approvalType, fields, origin }, status),
|
|
317
337
|
);
|
|
318
338
|
return this.getSegments();
|
|
319
339
|
}
|
|
@@ -324,7 +344,7 @@ export class MessageSegmentAccumulator {
|
|
|
324
344
|
* Three paths produce these: `addApprovalRequest` (live stream and replay),
|
|
325
345
|
* `flushPendingApprovals` (tracked-but-unresolved after a history replay) and
|
|
326
346
|
* `processApprovalResult` (a result arriving for a tracked request). They used
|
|
327
|
-
* to hand-write the object each, which is how `fields` reached
|
|
347
|
+
* to hand-write the object each, which is how `fields` and `origin` reached
|
|
328
348
|
* the card down one path and not the others — the same proposal rendering as a
|
|
329
349
|
* bare title, or losing the marker that routes its buttons to the hub.
|
|
330
350
|
*/
|
|
@@ -341,6 +361,7 @@ export class MessageSegmentAccumulator {
|
|
|
341
361
|
requestId,
|
|
342
362
|
approvalType: approval.approvalType,
|
|
343
363
|
...(approval.fields && approval.fields.length > 0 ? { fields: approval.fields } : {}),
|
|
364
|
+
...(approval.origin ? { origin: approval.origin } : {}),
|
|
344
365
|
},
|
|
345
366
|
status,
|
|
346
367
|
onApprove: this.callbacks.onApprove,
|
|
@@ -368,6 +389,9 @@ export class MessageSegmentAccumulator {
|
|
|
368
389
|
status: ChatApprovalStatus = 'pending',
|
|
369
390
|
executions?: Record<string, ApprovalBatchExecutionState>,
|
|
370
391
|
resolvedByName?: string | null,
|
|
392
|
+
/** Where the batch came from; `'guide'` keeps it inline and routes it to
|
|
393
|
+
* the hub, exactly as for a single card (see `ApprovalBatchData.origin`). */
|
|
394
|
+
origin?: 'guide',
|
|
371
395
|
): MessageSegment[] {
|
|
372
396
|
const existingIndex = this.segments.findIndex(
|
|
373
397
|
(s): s is ApprovalBatchSegment => s.type === 'approval_batch' && s.data.approvalRequestId === approvalRequestId,
|
|
@@ -376,6 +400,9 @@ export class MessageSegmentAccumulator {
|
|
|
376
400
|
if (existingIndex !== -1) {
|
|
377
401
|
const existing = this.segments[existingIndex] as ApprovalBatchSegment;
|
|
378
402
|
const mergedExecutions = executions ?? existing.data.executions;
|
|
403
|
+
// An upsert must not strip the marker: the flip to `approved` comes from
|
|
404
|
+
// a later event that carries no origin of its own.
|
|
405
|
+
const mergedOrigin = origin ?? existing.data.origin;
|
|
379
406
|
this.segments[existingIndex] = {
|
|
380
407
|
...existing,
|
|
381
408
|
data: {
|
|
@@ -383,6 +410,7 @@ export class MessageSegmentAccumulator {
|
|
|
383
410
|
approvalType,
|
|
384
411
|
toolCalls,
|
|
385
412
|
...(mergedExecutions ? { executions: mergedExecutions } : {}),
|
|
413
|
+
...(mergedOrigin ? { origin: mergedOrigin } : {}),
|
|
386
414
|
},
|
|
387
415
|
status,
|
|
388
416
|
resolvedByName: resolvedByName ?? existing.resolvedByName,
|
|
@@ -399,6 +427,7 @@ export class MessageSegmentAccumulator {
|
|
|
399
427
|
approvalType,
|
|
400
428
|
toolCalls,
|
|
401
429
|
...(executions ? { executions } : {}),
|
|
430
|
+
...(origin ? { origin } : {}),
|
|
402
431
|
},
|
|
403
432
|
status,
|
|
404
433
|
resolvedByName,
|
|
@@ -656,6 +685,9 @@ export class MessageSegmentAccumulator {
|
|
|
656
685
|
case 'thinking':
|
|
657
686
|
if (segment.text) this.appendThinking(segment.text);
|
|
658
687
|
break;
|
|
688
|
+
case 'guide':
|
|
689
|
+
if (segment.text) this.appendGuide(segment.text);
|
|
690
|
+
break;
|
|
659
691
|
case 'ask':
|
|
660
692
|
this.addAsk(segment.question, segment.options);
|
|
661
693
|
break;
|
|
@@ -671,6 +703,7 @@ export class MessageSegmentAccumulator {
|
|
|
671
703
|
data.approvalType || '',
|
|
672
704
|
status,
|
|
673
705
|
data.fields,
|
|
706
|
+
data.origin,
|
|
674
707
|
);
|
|
675
708
|
break;
|
|
676
709
|
}
|
|
@@ -683,6 +716,7 @@ export class MessageSegmentAccumulator {
|
|
|
683
716
|
status,
|
|
684
717
|
data.executions,
|
|
685
718
|
resolvedByName,
|
|
719
|
+
data.origin,
|
|
686
720
|
);
|
|
687
721
|
break;
|
|
688
722
|
}
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from '../../../chat-protocol/events';
|
|
34
34
|
// One normalizer for ask rows, shared with the live decoder — history and the
|
|
35
35
|
// stream must agree on which options are usable.
|
|
36
|
-
import { normalizeAskOptions } from '../../../chat-protocol/nats-decoder';
|
|
36
|
+
import { guideFrameEvent, normalizeAskOptions } from '../../../chat-protocol/nats-decoder';
|
|
37
37
|
import { applyApprovalStatusToSegment } from '../stream/message-mutations';
|
|
38
38
|
import {
|
|
39
39
|
MESSAGE_TYPE,
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
type MessageData,
|
|
48
48
|
type MessageOwner,
|
|
49
49
|
} from '../types';
|
|
50
|
-
import { approvalDisplaysInline } from './approval-display';
|
|
50
|
+
import { approvalDisplaysInline, guideApprovalOrigin } from './approval-display';
|
|
51
51
|
import { type MessageSegmentAccumulator, createMessageSegmentAccumulator } from './message-segment-accumulator';
|
|
52
52
|
import { getCommandText } from './tool-call-helpers';
|
|
53
53
|
|
|
@@ -121,6 +121,22 @@ export function decodeHistoricalMessageData(data: MessageData): ChatStreamEvent
|
|
|
121
121
|
}
|
|
122
122
|
return null;
|
|
123
123
|
|
|
124
|
+
// Two persisted shapes, mirroring the live `GUIDE` chunk: the answer body
|
|
125
|
+
// (`text`) and a Product Guide frame the agent re-streamed (`payload`,
|
|
126
|
+
// persisted so a card survives a reload). Decoded through the SAME
|
|
127
|
+
// `guideFrameEvent` the live path uses — a second mapping here would let
|
|
128
|
+
// history and realtime disagree about the same bytes.
|
|
129
|
+
case MESSAGE_TYPE.GUIDE: {
|
|
130
|
+
if ('text' in data && data.text) {
|
|
131
|
+
return { type: 'guide-delta', text: data.text };
|
|
132
|
+
}
|
|
133
|
+
const payload = 'payload' in data ? data.payload : undefined;
|
|
134
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
135
|
+
return guideFrameEvent(payload);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
// Same completeness gate as the live decoder (`decodeNatsChunk`): a
|
|
125
141
|
// persisted row without a question or without options is not a card the
|
|
126
142
|
// user can answer, so it replays as nothing rather than as empty chrome.
|
|
@@ -381,6 +397,10 @@ function applyHistoryEvent(
|
|
|
381
397
|
accumulator.appendThinking(event.text);
|
|
382
398
|
break;
|
|
383
399
|
|
|
400
|
+
case 'guide-delta':
|
|
401
|
+
accumulator.appendGuide(event.text);
|
|
402
|
+
break;
|
|
403
|
+
|
|
384
404
|
// Mirror of the live path: the intro sentence replays as answer text in
|
|
385
405
|
// front of the card, so a reloaded thread reads exactly like the stream did.
|
|
386
406
|
case 'ask':
|
|
@@ -397,13 +417,23 @@ function applyHistoryEvent(
|
|
|
397
417
|
const toolCalls = event.toolCalls;
|
|
398
418
|
const isBatch = !!toolCalls && toolCalls.length > 0;
|
|
399
419
|
// Same rule the live kernels use — a card must not change where it
|
|
400
|
-
// renders
|
|
401
|
-
// history instead of the stream.
|
|
402
|
-
|
|
420
|
+
// renders (or which backend its buttons hit) just because the page was
|
|
421
|
+
// reloaded and it came back through history instead of the stream.
|
|
422
|
+
const guideOrigin = guideApprovalOrigin(event);
|
|
423
|
+
|
|
424
|
+
if (approvalDisplaysInline(event, approvalType, displayApprovalTypes)) {
|
|
403
425
|
if (isBatch) {
|
|
404
426
|
const status = (approvalStatuses[event.requestId] as ChatApprovalStatus) || 'pending';
|
|
405
427
|
if (batchApprovalsEnabled) {
|
|
406
|
-
accumulator.addApprovalBatch(
|
|
428
|
+
accumulator.addApprovalBatch(
|
|
429
|
+
event.requestId,
|
|
430
|
+
approvalType,
|
|
431
|
+
toolCalls,
|
|
432
|
+
status,
|
|
433
|
+
undefined,
|
|
434
|
+
undefined,
|
|
435
|
+
guideOrigin,
|
|
436
|
+
);
|
|
407
437
|
} else {
|
|
408
438
|
// Flag OFF — unfold batch into N legacy approval cards (same id).
|
|
409
439
|
for (const call of toolCalls) {
|
|
@@ -415,6 +445,7 @@ function applyHistoryEvent(
|
|
|
415
445
|
approvalType,
|
|
416
446
|
status,
|
|
417
447
|
undefined,
|
|
448
|
+
guideOrigin,
|
|
418
449
|
);
|
|
419
450
|
}
|
|
420
451
|
}
|
|
@@ -428,7 +459,12 @@ function applyHistoryEvent(
|
|
|
428
459
|
// and honor `approvalStatuses`.
|
|
429
460
|
const resolvedStatus = approvalStatuses[event.requestId] as ChatApprovalStatus | undefined;
|
|
430
461
|
const isResolved = resolvedStatus === 'approved' || resolvedStatus === 'rejected';
|
|
431
|
-
|
|
462
|
+
// A guide card is added inline even while pending. The tracked path
|
|
463
|
+
// below ends in `flushPendingApprovals`, whose segments the consumer
|
|
464
|
+
// lifts into a sticky footer — that is the treatment for the
|
|
465
|
+
// consumer's OWN approvals, and it is not how the hub's chat renders
|
|
466
|
+
// a proposal (nor where its preamble expects it).
|
|
467
|
+
if (guideOrigin || isResolved) {
|
|
432
468
|
accumulator.addApprovalRequest(
|
|
433
469
|
event.requestId,
|
|
434
470
|
event.command || '',
|
|
@@ -436,6 +472,7 @@ function applyHistoryEvent(
|
|
|
436
472
|
approvalType,
|
|
437
473
|
resolvedStatus ?? 'pending',
|
|
438
474
|
event.fields,
|
|
475
|
+
guideOrigin,
|
|
439
476
|
);
|
|
440
477
|
} else {
|
|
441
478
|
accumulator.trackApprovalRequest(event.requestId, {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* (`title` / `url` / `targetPlatform` / `sourceRepo`). `resolveSourceRowCTA`
|
|
12
12
|
* then routes that `externalUrl` through the host's `composeContentUrl`
|
|
13
13
|
* seam and the card is clickable.
|
|
14
|
-
* - **Mingo / NATS transport** (`messageData: [{ type: 'TEXT', text }]`)
|
|
14
|
+
* - **Mingo / NATS transport** (`messageData: [{ type: 'GUIDE' | 'TEXT', text }]`)
|
|
15
15
|
* — the body carries bare `[card://type:id]` markers and NOTHING else.
|
|
16
16
|
* `chat-message-enhanced.tsx` synthesizes a minimal `{ type, id, title: id,
|
|
17
17
|
* url: null }` ref so fetch-mode cards can still self-fetch their row, but
|
|
@@ -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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
+
}
|