@carlonicora/nextjs-jsonapi 3.25.0 → 3.26.0
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/{BlockNoteEditor-HKS6AXDA.js → BlockNoteEditor-4EIKMNY3.js} +20 -10
- package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
- package/dist/{BlockNoteEditor-46O3JC4Z.mjs → BlockNoteEditor-AXYVDDXM.mjs} +13 -3
- package/dist/{BlockNoteEditor-46O3JC4Z.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
- package/dist/billing/index.js +335 -335
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-Y3OKEQGL.js → chunk-ABVTYQIJ.js} +578 -363
- package/dist/chunk-ABVTYQIJ.js.map +1 -0
- package/dist/{chunk-KLV6RLDE.js → chunk-GKDHYB2R.js} +2 -2
- package/dist/chunk-GKDHYB2R.js.map +1 -0
- package/dist/{chunk-XDYILJ2S.mjs → chunk-QWZAGQWJ.mjs} +546 -331
- package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
- package/dist/{chunk-4Z2UONN2.mjs → chunk-U65JPKO3.mjs} +2 -2
- package/dist/chunk-U65JPKO3.mjs.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.d.mts +16 -3
- package/dist/components/index.d.ts +16 -3
- package/dist/components/index.js +4 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +3 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/features/analytics/index.js +100 -100
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +5 -5
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/help/index.js +36 -36
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +6 -6
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.js +103 -103
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/containers/PageContainer.tsx +1 -1
- package/src/components/containers/RoundPageContainer.tsx +60 -16
- package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
- package/src/components/editors/BlockNoteEditor.tsx +13 -1
- package/src/components/forms/EntityMultiSelector.tsx +4 -4
- package/src/components/forms/EntitySelector.tsx +4 -4
- package/src/components/forms/FormBlockNote.tsx +21 -1
- package/src/components/forms/FormCheckbox.tsx +6 -2
- package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
- package/src/components/forms/useEditorDialog.ts +22 -0
- package/src/components/navigations/Breadcrumb.tsx +45 -16
- package/src/components/navigations/Header.tsx +16 -3
- package/src/components/navigations/MobileNavigationBar.tsx +12 -0
- package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
- package/src/components/tables/ContentListTable.tsx +52 -32
- package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +9 -6
- package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +2 -1
- package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
- package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
- package/src/features/assistant-message/components/MessageItem.tsx +2 -2
- package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
- package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
- package/src/features/company/components/details/CompanyContent.tsx +3 -1
- package/src/features/company/components/details/CompanyDetails.tsx +9 -1
- package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
- package/src/features/help/components/HelpHeader.tsx +7 -5
- package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
- package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
- package/src/features/tokenusage/lib/formatters.ts +5 -1
- package/src/shadcnui/ui/button.tsx +1 -1
- package/src/shadcnui/ui/checkbox.tsx +1 -1
- package/src/shadcnui/ui/command.tsx +4 -4
- package/src/shadcnui/ui/dialog.tsx +2 -0
- package/src/shadcnui/ui/input.tsx +1 -1
- package/src/shadcnui/ui/popover.tsx +9 -0
- package/src/shadcnui/ui/select.tsx +1 -1
- package/src/shadcnui/ui/sheet.tsx +8 -1
- package/src/shadcnui/ui/table.tsx +2 -2
- package/src/shadcnui/ui/textarea.tsx +1 -1
- package/dist/BlockNoteEditor-HKS6AXDA.js.map +0 -1
- package/dist/chunk-4Z2UONN2.mjs.map +0 -1
- package/dist/chunk-KLV6RLDE.js.map +0 -1
- package/dist/chunk-XDYILJ2S.mjs.map +0 -1
- package/dist/chunk-Y3OKEQGL.js.map +0 -1
|
@@ -19,7 +19,7 @@ export function PageContainer({ children, testId, className }: PageContainerProp
|
|
|
19
19
|
<Header leftContent={headerLeftContent} logo={headerLogo} mobileChildren={headerMobileChildren}>
|
|
20
20
|
{headerChildren}
|
|
21
21
|
</Header>
|
|
22
|
-
<main className={cn(`flex w-full flex-1 flex-col gap-y-4 pt-4 ps-4 pe-0`, className)}>{children}</main>
|
|
22
|
+
<main className={cn(`flex w-full flex-1 flex-col gap-y-4 pt-4 ps-4 pe-0 max-md:pe-4`, className)}>{children}</main>
|
|
23
23
|
</div>
|
|
24
24
|
);
|
|
25
25
|
}
|
|
@@ -21,7 +21,13 @@ import { partitionTabs, Tab } from "@/components/containers";
|
|
|
21
21
|
import { HEADER_ROW_MIN_H, RoundPageContainerTitle } from "@/components/containers/RoundPageContainerTitle";
|
|
22
22
|
import { Header, MobileNavigationBar } from "@/components/navigations";
|
|
23
23
|
import { MicroLabel } from "@/components/typography";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
useHeaderChildren,
|
|
26
|
+
useHeaderLeftContent,
|
|
27
|
+
useHeaderLogo,
|
|
28
|
+
useHeaderMobileChildren,
|
|
29
|
+
useMobileNavigationItems,
|
|
30
|
+
} from "@/contexts";
|
|
25
31
|
import { useUrlRewriter } from "@/hooks";
|
|
26
32
|
import { cn, useIsMobile } from "@/index";
|
|
27
33
|
import { useOptionalSidebar } from "@/shadcnui";
|
|
@@ -186,7 +192,15 @@ export function RoundPageContainer({
|
|
|
186
192
|
const headerLogo = useHeaderLogo();
|
|
187
193
|
const headerMobileChildren = useHeaderMobileChildren();
|
|
188
194
|
const [showDetails, setShowDetailsState] = useState(defaultDetailsOpen);
|
|
195
|
+
// Phone-only open state for the details Sheet. It always starts closed and is
|
|
196
|
+
// never written to the cookie: on a phone the panel is a full-screen overlay,
|
|
197
|
+
// so a remembered "open" (set on desktop, where it is a side column) covered
|
|
198
|
+
// the page on every visit — the assistant's Conversations panel did exactly that.
|
|
199
|
+
const [showDetailsMobile, setShowDetailsMobile] = useState(false);
|
|
189
200
|
const isMobile = useIsMobile();
|
|
201
|
+
// The bottom bar's "Menu" slot opens the same drawer as the header's sidebar
|
|
202
|
+
// toggle, so the header drops its toggle on a phone whenever the bar renders.
|
|
203
|
+
const hasMobileNavigation = useMobileNavigationItems().length > 0;
|
|
190
204
|
// The desktop shell drops its start padding only where a sidebar sits against it. A page
|
|
191
205
|
// without one (the customer portal) keeps the padding on both sides.
|
|
192
206
|
const hasSidebar = useOptionalSidebar() !== null;
|
|
@@ -298,6 +312,21 @@ export function RoundPageContainer({
|
|
|
298
312
|
|
|
299
313
|
const isReady = mounted;
|
|
300
314
|
|
|
315
|
+
// Phone, viewport-bound shell: the whole card becomes the single scroller, so
|
|
316
|
+
// the title row, command row, artwork and section picker scroll away with the
|
|
317
|
+
// content instead of staying pinned and leaving the content less than half the
|
|
318
|
+
// screen. That covers ordinary tabs AND document tabs (fill-height + constrained
|
|
319
|
+
// width: the entity's own editor, which then grows with its text). Only bare
|
|
320
|
+
// fill-height tabs (graph, map, recordings) and full-width pages (lists, canvas,
|
|
321
|
+
// run) keep the inner scroller, since they need a definite height. Desktop is
|
|
322
|
+
// untouched.
|
|
323
|
+
const activeDocumentTab = activeFillHeight && activeConstrainWidth;
|
|
324
|
+
const mobileFlow = isMobile && isFixed && (!activeFillHeight || activeDocumentTab) && !fullWidth;
|
|
325
|
+
const innerClip = mobileFlow ? `` : clip;
|
|
326
|
+
const innerScrollY = mobileFlow ? `` : scrollY;
|
|
327
|
+
// A single section needs no picker.
|
|
328
|
+
const showMobileSectionPicker = (tabs?.length ?? 0) > 1;
|
|
329
|
+
|
|
301
330
|
if (!isReady) {
|
|
302
331
|
return (
|
|
303
332
|
<>
|
|
@@ -305,6 +334,7 @@ export function RoundPageContainer({
|
|
|
305
334
|
leftContent={headerLeftContent}
|
|
306
335
|
logo={headerLogo}
|
|
307
336
|
mobileChildren={headerMobileChildren}
|
|
337
|
+
hideSidebarTriggerOnMobile={hasMobileNavigation}
|
|
308
338
|
className="bg-sidebar border-0"
|
|
309
339
|
>
|
|
310
340
|
{headerChildren}
|
|
@@ -349,6 +379,7 @@ export function RoundPageContainer({
|
|
|
349
379
|
leftContent={headerLeftContent}
|
|
350
380
|
logo={headerLogo}
|
|
351
381
|
mobileChildren={headerMobileChildren}
|
|
382
|
+
hideSidebarTriggerOnMobile={hasMobileNavigation}
|
|
352
383
|
className="bg-sidebar border-0"
|
|
353
384
|
>
|
|
354
385
|
{headerChildren}
|
|
@@ -379,19 +410,21 @@ export function RoundPageContainer({
|
|
|
379
410
|
the wrapper, leaving the bar no room and pushing it below the fold —
|
|
380
411
|
it then appears only after scrolling to the very end of the page. */}
|
|
381
412
|
<div className="bg-background flex min-h-0 w-full flex-1 rounded-lg border p-0">
|
|
382
|
-
|
|
413
|
+
{/* pb-16 under mobileFlow: room for the floating Help button, which
|
|
414
|
+
otherwise sits over the last lines of the page. */}
|
|
415
|
+
<div className={cn("flex w-full flex-col", mobileFlow && "min-h-0 overflow-y-auto pb-16")}>
|
|
383
416
|
{(!fullWidth || forceHeader) && (
|
|
384
417
|
<RoundPageContainerTitle
|
|
385
418
|
module={module}
|
|
386
419
|
details={details}
|
|
387
420
|
detailsTitle={detailsTitle}
|
|
388
421
|
detailsIcon={detailsIcon}
|
|
389
|
-
showDetails={showDetails}
|
|
390
|
-
setShowDetails={setShowDetails}
|
|
422
|
+
showDetails={isMobile ? showDetailsMobile : showDetails}
|
|
423
|
+
setShowDetails={isMobile ? setShowDetailsMobile : setShowDetails}
|
|
391
424
|
fullWidth={fullWidth}
|
|
392
425
|
/>
|
|
393
426
|
)}
|
|
394
|
-
<div className={cn(`flex w-full`, isFixed && `h-full`,
|
|
427
|
+
<div className={cn(`flex w-full`, isFixed && !mobileFlow && `h-full`, innerClip)}>
|
|
395
428
|
{layout === "rail" && tabs ? (
|
|
396
429
|
// Rail layout: the vertical-tab navigation is a flush-left
|
|
397
430
|
// sidebar of the card and the content fills the full remaining
|
|
@@ -409,7 +442,11 @@ export function RoundPageContainer({
|
|
|
409
442
|
// the shadcn root's default `data-[orientation=horizontal]:flex-col`
|
|
410
443
|
// to keep the rail and content side by side.
|
|
411
444
|
orientation="horizontal"
|
|
412
|
-
className={cn(
|
|
445
|
+
className={cn(
|
|
446
|
+
`flex min-w-0 grow data-[orientation=horizontal]:flex-row`,
|
|
447
|
+
isFixed && !mobileFlow && `h-full`,
|
|
448
|
+
innerClip,
|
|
449
|
+
)}
|
|
413
450
|
>
|
|
414
451
|
{/* Flush-left section rail — md and up */}
|
|
415
452
|
<aside
|
|
@@ -454,14 +491,17 @@ export function RoundPageContainer({
|
|
|
454
491
|
</aside>
|
|
455
492
|
|
|
456
493
|
{/* Content — full width, fills the remaining space */}
|
|
457
|
-
<div className={cn(`flex min-w-0 grow flex-col`,
|
|
494
|
+
<div className={cn(`flex min-w-0 grow flex-col`, innerClip)}>
|
|
458
495
|
{/* The rail is hidden below md, so its header would vanish with
|
|
459
496
|
it — render it above the section Select instead, capped to
|
|
460
497
|
the rail's width so a phone does not open on a full-bleed
|
|
461
498
|
image. */}
|
|
462
|
-
{railHeader && <div className="mx-auto w-full max-w-
|
|
463
|
-
{/* Section Select — below md */}
|
|
464
|
-
<div
|
|
499
|
+
{railHeader && <div className="mx-auto w-full max-w-24 px-2 pt-2 md:hidden">{railHeader}</div>}
|
|
500
|
+
{/* Section Select — below md, and only when there is a choice to make */}
|
|
501
|
+
<div
|
|
502
|
+
data-testid="round-page-rail-select"
|
|
503
|
+
className={cn("p-2 md:hidden", !showMobileSectionPicker && "hidden")}
|
|
504
|
+
>
|
|
465
505
|
<Select
|
|
466
506
|
items={tabItems}
|
|
467
507
|
value={activeTab}
|
|
@@ -492,7 +532,7 @@ export function RoundPageContainer({
|
|
|
492
532
|
</Select>
|
|
493
533
|
</div>
|
|
494
534
|
<div
|
|
495
|
-
className={cn(`min-w-0 grow`, activeFillHeight ? cn(`flex flex-col`,
|
|
535
|
+
className={cn(`min-w-0 grow`, activeFillHeight ? cn(`flex flex-col`, innerClip) : cn(innerScrollY, `p-4`))}
|
|
496
536
|
>
|
|
497
537
|
{/* Centre and constrain rail content (like the non-rail
|
|
498
538
|
layout). Fill-height tabs are full-bleed — a canvas, a
|
|
@@ -524,7 +564,7 @@ export function RoundPageContainer({
|
|
|
524
564
|
className={cn(
|
|
525
565
|
`grow`,
|
|
526
566
|
isMobile ? `p-2` : `p-4`,
|
|
527
|
-
activeFillHeight ? cn(`flex flex-col`,
|
|
567
|
+
activeFillHeight ? cn(`flex flex-col`, innerClip) : innerScrollY,
|
|
528
568
|
fullWidth && `p-0`,
|
|
529
569
|
)}
|
|
530
570
|
>
|
|
@@ -544,7 +584,7 @@ export function RoundPageContainer({
|
|
|
544
584
|
onValueChange={handleTabChange}
|
|
545
585
|
>
|
|
546
586
|
{isMobile ? (
|
|
547
|
-
<div className="p-0">
|
|
587
|
+
<div className={cn("p-0", !showMobileSectionPicker && "hidden")}>
|
|
548
588
|
<Select
|
|
549
589
|
items={tabItems}
|
|
550
590
|
value={activeTab}
|
|
@@ -579,7 +619,7 @@ export function RoundPageContainer({
|
|
|
579
619
|
className={cn(
|
|
580
620
|
`flex w-full `,
|
|
581
621
|
isMobile ? `` : `px-4`,
|
|
582
|
-
activeFillHeight ? `flex-1 min-h-0` :
|
|
622
|
+
activeFillHeight ? `flex-1 min-h-0` : innerScrollY,
|
|
583
623
|
)}
|
|
584
624
|
>
|
|
585
625
|
{tabs.map((tab) => (
|
|
@@ -605,8 +645,12 @@ export function RoundPageContainer({
|
|
|
605
645
|
</div>
|
|
606
646
|
{details &&
|
|
607
647
|
(isMobile ? (
|
|
608
|
-
<Sheet open={
|
|
609
|
-
|
|
648
|
+
<Sheet open={showDetailsMobile} onOpenChange={setShowDetailsMobile}>
|
|
649
|
+
{/* Full width: the default 75% left a useless strip of page beside it. */}
|
|
650
|
+
<SheetContent
|
|
651
|
+
side="end"
|
|
652
|
+
className="data-[side=left]:w-full data-[side=right]:w-full data-[side=left]:sm:max-w-none data-[side=right]:sm:max-w-none"
|
|
653
|
+
>
|
|
610
654
|
<SheetHeader>
|
|
611
655
|
<SheetTitle>{detailsTitle ?? "Details"}</SheetTitle>
|
|
612
656
|
</SheetHeader>
|
|
@@ -38,10 +38,74 @@ export function RoundPageContainerTitle({
|
|
|
38
38
|
const { title } = useSharedContext();
|
|
39
39
|
const isMobile = useIsMobile();
|
|
40
40
|
|
|
41
|
+
// A Tooltip on the toggle stays stuck on screen after a tap, so a phone gets
|
|
42
|
+
// the bare button. The aria-label carries the same text the tooltip shows,
|
|
43
|
+
// since the icon alone does not name the control.
|
|
44
|
+
const detailsLabel = `${showDetails ? "Hide" : "Show"} ${typeof detailsTitle === "string" ? detailsTitle : "details"}`;
|
|
45
|
+
const detailsToggle = details ? (
|
|
46
|
+
isMobile ? (
|
|
47
|
+
<Button
|
|
48
|
+
data-help="page.details"
|
|
49
|
+
variant={showDetails ? `ghost` : `default`}
|
|
50
|
+
onClick={() => setShowDetails(!showDetails)}
|
|
51
|
+
aria-label={detailsLabel}
|
|
52
|
+
className="shrink-0"
|
|
53
|
+
>
|
|
54
|
+
{detailsIcon ?? <InfoIcon />}
|
|
55
|
+
</Button>
|
|
56
|
+
) : (
|
|
57
|
+
<Tooltip>
|
|
58
|
+
{/* render prop, not a Button nested inside the trigger: the trigger
|
|
59
|
+
renders its own <button>, and a button inside a button is invalid. */}
|
|
60
|
+
<TooltipTrigger
|
|
61
|
+
render={
|
|
62
|
+
<Button
|
|
63
|
+
data-help="page.details"
|
|
64
|
+
variant={showDetails ? `ghost` : `default`}
|
|
65
|
+
onClick={() => setShowDetails(!showDetails)}
|
|
66
|
+
aria-label={detailsLabel}
|
|
67
|
+
className={cn(`cursor-pointer`)}
|
|
68
|
+
/>
|
|
69
|
+
}
|
|
70
|
+
>
|
|
71
|
+
{detailsIcon ?? <InfoIcon />}
|
|
72
|
+
</TooltipTrigger>
|
|
73
|
+
<TooltipContent>
|
|
74
|
+
{showDetails ? "Hide" : "Show"} {detailsTitle ?? "details"}
|
|
75
|
+
</TooltipContent>
|
|
76
|
+
</Tooltip>
|
|
77
|
+
)
|
|
78
|
+
) : null;
|
|
79
|
+
|
|
41
80
|
return (
|
|
42
81
|
<div className="flex w-full flex-col border-b">
|
|
43
|
-
|
|
44
|
-
|
|
82
|
+
{isMobile ? (
|
|
83
|
+
// Phone: "type · name" on one truncated line with the details toggle
|
|
84
|
+
// beside it; the page controls drop to their own wrapping line below,
|
|
85
|
+
// so they can never push the page wider than the screen.
|
|
86
|
+
<div className={cn(`flex w-full flex-col gap-y-2 p-4`, HEADER_ROW_MIN_H)}>
|
|
87
|
+
<div className="flex w-full min-w-0 items-center justify-between gap-x-2">
|
|
88
|
+
<div className="text-muted-foreground flex min-w-0 items-center gap-x-2 text-base font-light">
|
|
89
|
+
{title.titleActions}
|
|
90
|
+
{module && module.icon ? <module.icon className="text-primary h-5 w-5 shrink-0" /> : title.icon}
|
|
91
|
+
<span className="truncate">
|
|
92
|
+
{title.type}
|
|
93
|
+
{title.element && (
|
|
94
|
+
<>
|
|
95
|
+
<span aria-hidden> · </span>
|
|
96
|
+
<span className="text-primary font-semibold">{title.element}</span>
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
{detailsToggle}
|
|
102
|
+
</div>
|
|
103
|
+
{title.functions && (
|
|
104
|
+
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2">{title.functions}</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
) : (
|
|
108
|
+
<div className={cn(`flex w-full flex-row items-center gap-x-2 p-4 justify-between`, HEADER_ROW_MIN_H)}>
|
|
45
109
|
<div className="flex w-full gap-x-4">
|
|
46
110
|
<div className={"text-muted-foreground flex items-center gap-x-2 text-lg font-light whitespace-nowrap"}>
|
|
47
111
|
{title.titleActions}
|
|
@@ -50,43 +114,19 @@ export function RoundPageContainerTitle({
|
|
|
50
114
|
</div>
|
|
51
115
|
<div className={cn("text-primary w-full text-xl font-semibold")}>{title.element}</div>
|
|
52
116
|
</div>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<div className="flex shrink-0 items-center gap-x-2">
|
|
62
|
-
{title.functions}
|
|
63
|
-
{details && (
|
|
64
|
-
<Tooltip>
|
|
65
|
-
<TooltipTrigger>
|
|
66
|
-
<Button
|
|
67
|
-
data-help="page.details"
|
|
68
|
-
render={<div />}
|
|
69
|
-
nativeButton={false}
|
|
70
|
-
variant={showDetails ? `ghost` : `default`}
|
|
71
|
-
onClick={() => setShowDetails(!showDetails)}
|
|
72
|
-
className={cn(`cursor-pointer`)}
|
|
73
|
-
>
|
|
74
|
-
{detailsIcon ?? <InfoIcon />}
|
|
75
|
-
</Button>
|
|
76
|
-
</TooltipTrigger>
|
|
77
|
-
<TooltipContent>
|
|
78
|
-
{showDetails ? "Hide" : "Show"} {detailsTitle ?? "details"}
|
|
79
|
-
</TooltipContent>
|
|
80
|
-
</Tooltip>
|
|
81
|
-
)}
|
|
82
|
-
</div>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
117
|
+
{(title.functions || details) && (
|
|
118
|
+
<div className="flex shrink-0 items-center gap-x-2">
|
|
119
|
+
{title.functions}
|
|
120
|
+
{detailsToggle}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
85
125
|
{title.actionBar && (
|
|
86
126
|
<div
|
|
87
127
|
data-testid="round-page-action-bar"
|
|
88
128
|
data-help="page.action-bar"
|
|
89
|
-
className="flex w-full items-center gap-x-2 border-t px-4 py-2"
|
|
129
|
+
className="flex w-full items-center gap-x-2 border-t px-4 py-2 max-md:flex-wrap max-md:gap-y-2"
|
|
90
130
|
>
|
|
91
131
|
{/* The bar is the page's command row, so the commands inside it label
|
|
92
132
|
themselves ("Edit", "Delete") instead of rendering the bare glyph
|
|
@@ -872,7 +872,12 @@ export default function BlockNoteEditor({
|
|
|
872
872
|
// font-size: 16px }` explicitly; outside AI mode the form's text-sm
|
|
873
873
|
// wins via cascade, but ForkYDocExtension re-evaluates the style
|
|
874
874
|
// context on AI activation and the explicit 16px takes over.
|
|
875
|
-
|
|
875
|
+
// Phone: an editable field types at 16px, or iPhone Safari zooms the
|
|
876
|
+
// page in on focus. The two ranges never overlap, so neither depends on
|
|
877
|
+
// CSS source order to win.
|
|
878
|
+
onChange
|
|
879
|
+
? "md:[&_.bn-default-styles]:!text-sm max-md:[&_.bn-default-styles]:!text-base"
|
|
880
|
+
: "[&_.bn-default-styles]:!text-sm",
|
|
876
881
|
className,
|
|
877
882
|
)}
|
|
878
883
|
>
|
|
@@ -898,6 +903,13 @@ export default function BlockNoteEditor({
|
|
|
898
903
|
// — and the surrounding EditorSheet form — to scroll instead of
|
|
899
904
|
// scrolling internally.
|
|
900
905
|
onChange && stretch && "[&.bn-container]:min-h-0 [&.bn-container]:overflow-y-auto",
|
|
906
|
+
// Phone — these reach the floating menus because BlockNote puts this
|
|
907
|
+
// className on its portal root too. The formatting toolbar (~450px)
|
|
908
|
+
// scrolls sideways instead of losing alignment and link off-screen;
|
|
909
|
+
// the slash menu is capped to the screen and drops its keyboard
|
|
910
|
+
// shortcut badges, which mean nothing on a touch screen.
|
|
911
|
+
"max-md:[&_.bn-formatting-toolbar]:max-w-[calc(100vw-1rem)] max-md:[&_.bn-formatting-toolbar]:overflow-x-auto max-md:[&_.bn-formatting-toolbar>*]:shrink-0",
|
|
912
|
+
"max-md:[&_.bn-suggestion-menu]:max-w-[calc(100vw-1rem)] max-md:[&_.bn-suggestion-menu_[role=option]>[data-position=right]]:hidden",
|
|
901
913
|
size === "sm" && "small",
|
|
902
914
|
)}
|
|
903
915
|
>
|
|
@@ -225,7 +225,7 @@ export function EntityMultiSelector<T extends { id: string }>({
|
|
|
225
225
|
<Popover open={disabled ? false : open} onOpenChange={disabled ? undefined : setOpen} modal>
|
|
226
226
|
<PopoverTrigger className="w-full" disabled={disabled}>
|
|
227
227
|
<div
|
|
228
|
-
className={`bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[2px] flex min-h-7 w-full items-center gap-2 rounded-md border px-2 text-sm md:text-xs/relaxed ${
|
|
228
|
+
className={`bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[2px] flex min-h-7 max-md:min-h-10 w-full items-center gap-2 rounded-md border px-2 text-sm md:text-xs/relaxed ${
|
|
229
229
|
disabled ? "cursor-not-allowed opacity-50" : ""
|
|
230
230
|
}`}
|
|
231
231
|
>
|
|
@@ -249,7 +249,7 @@ export function EntityMultiSelector<T extends { id: string }>({
|
|
|
249
249
|
ref={searchInputRef}
|
|
250
250
|
placeholder={placeholder}
|
|
251
251
|
type="text"
|
|
252
|
-
className="h-8 w-full pe-7 ps-7 text-xs"
|
|
252
|
+
className="h-8 w-full pe-7 ps-7 text-xs max-md:text-base"
|
|
253
253
|
value={searchTerm}
|
|
254
254
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
255
255
|
/>
|
|
@@ -263,7 +263,7 @@ export function EntityMultiSelector<T extends { id: string }>({
|
|
|
263
263
|
</button>
|
|
264
264
|
)}
|
|
265
265
|
</div>
|
|
266
|
-
<div className="max-h-52 overflow-y-auto p-1">
|
|
266
|
+
<div className="max-h-52 max-md:min-h-0 overflow-y-auto p-1">
|
|
267
267
|
{sortedOptions.length === 0 ? (
|
|
268
268
|
<div className="text-muted-foreground py-4 text-center text-xs">{emptyText}</div>
|
|
269
269
|
) : (
|
|
@@ -321,7 +321,7 @@ export function EntityMultiSelector<T extends { id: string }>({
|
|
|
321
321
|
{!disabled && (
|
|
322
322
|
<button
|
|
323
323
|
type="button"
|
|
324
|
-
className="text-muted-foreground hover:text-foreground rounded-sm p-0.5 transition-colors"
|
|
324
|
+
className="text-muted-foreground hover:text-foreground rounded-sm p-0.5 max-md:p-2 transition-colors"
|
|
325
325
|
onClick={() => removeEntity(value.id)}
|
|
326
326
|
>
|
|
327
327
|
<XIcon className="size-3" />
|
|
@@ -165,7 +165,7 @@ export default function EntitySelector<T extends BaseEntity>({
|
|
|
165
165
|
<Popover open={disabled ? false : open} onOpenChange={disabled ? undefined : setOpen} modal={true}>
|
|
166
166
|
<PopoverTrigger className="w-full" disabled={disabled}>
|
|
167
167
|
<div
|
|
168
|
-
className={`bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[2px] flex min-h-7 w-full items-center gap-2 rounded-md border px-2 py-0.5 text-sm md:text-xs/relaxed ${
|
|
168
|
+
className={`bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[2px] flex min-h-7 max-md:min-h-10 w-full items-center gap-2 rounded-md border px-2 py-0.5 text-sm md:text-xs/relaxed ${
|
|
169
169
|
hasValue(effectiveValue) ? "" : "text-muted-foreground"
|
|
170
170
|
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
|
|
171
171
|
>
|
|
@@ -191,7 +191,7 @@ export default function EntitySelector<T extends BaseEntity>({
|
|
|
191
191
|
<PopoverContent align="start" className="w-(--anchor-width) pointer-events-auto">
|
|
192
192
|
<Command shouldFilter={false}>
|
|
193
193
|
<div className="relative mb-2 w-full">
|
|
194
|
-
<SearchIcon className="text-muted-foreground absolute top-2.5 start-2.5 h-4 w-4" />
|
|
194
|
+
<SearchIcon className="text-muted-foreground absolute top-2.5 max-md:top-3 start-2.5 h-4 w-4" />
|
|
195
195
|
<Input
|
|
196
196
|
placeholder={placeholder}
|
|
197
197
|
type="text"
|
|
@@ -200,10 +200,10 @@ export default function EntitySelector<T extends BaseEntity>({
|
|
|
200
200
|
value={searchTerm}
|
|
201
201
|
/>
|
|
202
202
|
{isSearching ? (
|
|
203
|
-
<RefreshCwIcon className="text-muted-foreground absolute top-2.5 end-2.5 h-4 w-4 animate-spin" />
|
|
203
|
+
<RefreshCwIcon className="text-muted-foreground absolute top-2.5 max-md:top-3 end-2.5 h-4 w-4 animate-spin" />
|
|
204
204
|
) : searchTermRef.current ? (
|
|
205
205
|
<XIcon
|
|
206
|
-
className="absolute top-2.5 end-2.5 h-4 w-4 cursor-pointer"
|
|
206
|
+
className="absolute top-2.5 max-md:top-3 end-2.5 h-4 w-4 cursor-pointer"
|
|
207
207
|
onClick={() => {
|
|
208
208
|
setSearchTerm("");
|
|
209
209
|
search("");
|
|
@@ -7,6 +7,14 @@ import { BlockNoteEditorContainer } from "../editors/BlockNoteEditorContainer";
|
|
|
7
7
|
import type { MentionNameResolver, MentionResolveFn } from "../editors/BlockNoteEditorMentionInlineContent";
|
|
8
8
|
import { FormFieldWrapper } from "./FormFieldWrapper";
|
|
9
9
|
|
|
10
|
+
function isEmptyDocument(value: unknown): boolean {
|
|
11
|
+
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPath(source: unknown, path: string): unknown {
|
|
15
|
+
return path.split(".").reduce<unknown>((acc, key) => (acc == null ? undefined : (acc as Record<string, unknown>)[key]), source);
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export function FormBlockNote({
|
|
11
19
|
form,
|
|
12
20
|
id,
|
|
@@ -73,6 +81,10 @@ export function FormBlockNote({
|
|
|
73
81
|
className={cn(
|
|
74
82
|
"flex w-full flex-col",
|
|
75
83
|
stretch && "min-h-0 flex-1 [&>[data-slot=field]]:min-h-0 [&>[data-slot=field]]:flex-1",
|
|
84
|
+
// Phone: the sheet is too short to share with the other fields, so the
|
|
85
|
+
// editor stops filling the leftover space (2px in a long form) and grows
|
|
86
|
+
// with its content instead; the sheet body does the scrolling.
|
|
87
|
+
stretch && "max-md:min-h-64 max-md:flex-none",
|
|
76
88
|
className,
|
|
77
89
|
)}
|
|
78
90
|
>
|
|
@@ -100,7 +112,15 @@ export function FormBlockNote({
|
|
|
100
112
|
initialContent={initialContentRef.current}
|
|
101
113
|
onChange={(content, isEmpty) => {
|
|
102
114
|
lastEditorContentRef.current = content;
|
|
103
|
-
|
|
115
|
+
// BlockNote normalises an empty document ([] / null) to one empty
|
|
116
|
+
// paragraph as soon as it mounts. That is not a user edit: re-seed the
|
|
117
|
+
// field's default with it so an untouched form does not ask
|
|
118
|
+
// "Unsaved changes?" on close.
|
|
119
|
+
if (isEmpty && isEmptyDocument(getPath(form.formState.defaultValues, id)) && !form.getFieldState(id).isDirty) {
|
|
120
|
+
form.resetField(id, { defaultValue: content });
|
|
121
|
+
} else {
|
|
122
|
+
field.onChange(content);
|
|
123
|
+
}
|
|
104
124
|
onEmptyChange?.(isEmpty);
|
|
105
125
|
}}
|
|
106
126
|
placeholder={placeholder}
|
|
@@ -15,7 +15,11 @@ type FormCheckboxProps = {
|
|
|
15
15
|
export function FormCheckbox({ form, id, name, labelBefore, description, isRequired }: FormCheckboxProps) {
|
|
16
16
|
const simpleLabel = () => {
|
|
17
17
|
return (
|
|
18
|
-
|
|
18
|
+
// Phone: the label fills the row and is 40px tall, so the whole row toggles the box.
|
|
19
|
+
<FieldLabel
|
|
20
|
+
htmlFor={id}
|
|
21
|
+
className={`font-normal ${labelBefore ? "" : "ms-3"} max-md:min-h-10 max-md:flex-1`}
|
|
22
|
+
>
|
|
19
23
|
{name}
|
|
20
24
|
</FieldLabel>
|
|
21
25
|
);
|
|
@@ -36,7 +40,7 @@ export function FormCheckbox({ form, id, name, labelBefore, description, isRequi
|
|
|
36
40
|
<div className="flex w-full flex-col">
|
|
37
41
|
<FormFieldWrapper form={form} name={id} orientation="horizontal">
|
|
38
42
|
{(field) => (
|
|
39
|
-
<div className="flex gap-x-4">
|
|
43
|
+
<div className="flex gap-x-4 max-md:min-h-10 max-md:items-center">
|
|
40
44
|
{labelBefore && label()}
|
|
41
45
|
{labelBefore && isRequired && <span className="ms-1 text-destructive">*</span>}
|
|
42
46
|
<Checkbox id={id} checked={field.value ?? false} onCheckedChange={field.onChange} />
|
|
@@ -169,6 +169,53 @@ describe("useEditorDialog", () => {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
describe("escape key with an open picker / menu above the sheet", () => {
|
|
173
|
+
const pressEscape = () => {
|
|
174
|
+
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
|
175
|
+
act(() => {
|
|
176
|
+
document.dispatchEvent(event);
|
|
177
|
+
});
|
|
178
|
+
return event;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const mountLayer = (html: string) => {
|
|
182
|
+
const host = document.createElement("div");
|
|
183
|
+
host.innerHTML = html;
|
|
184
|
+
document.body.appendChild(host);
|
|
185
|
+
return () => host.remove();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
it.each([
|
|
189
|
+
['<div data-slot="popover-content" data-open></div>'],
|
|
190
|
+
['<div data-slot="select-content" data-open></div>'],
|
|
191
|
+
['<div data-slot="dropdown-menu-content" data-open></div>'],
|
|
192
|
+
['<div data-slot="dialog-content" data-open></div>'],
|
|
193
|
+
['<div class="bn-suggestion-menu"></div>'],
|
|
194
|
+
])("leaves Escape to the open layer %s and keeps the sheet open", (html) => {
|
|
195
|
+
const { result } = renderHook(() => useEditorDialog(() => true));
|
|
196
|
+
act(() => result.current.setOpen(true));
|
|
197
|
+
const unmount = mountLayer(html);
|
|
198
|
+
|
|
199
|
+
const event = pressEscape();
|
|
200
|
+
|
|
201
|
+
expect(result.current.open).toBe(true);
|
|
202
|
+
expect(result.current.discardDialogProps.open).toBe(false);
|
|
203
|
+
expect(event.defaultPrevented).toBe(false);
|
|
204
|
+
unmount();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("handles Escape again once the layer has closed", () => {
|
|
208
|
+
const { result } = renderHook(() => useEditorDialog(() => false));
|
|
209
|
+
act(() => result.current.setOpen(true));
|
|
210
|
+
const unmount = mountLayer('<div data-slot="popover-content" data-closed></div>');
|
|
211
|
+
|
|
212
|
+
pressEscape();
|
|
213
|
+
|
|
214
|
+
expect(result.current.open).toBe(false);
|
|
215
|
+
unmount();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
172
219
|
describe("escape key with nested editors", () => {
|
|
173
220
|
const pressEscape = () =>
|
|
174
221
|
act(() => {
|
|
@@ -11,6 +11,27 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
11
11
|
// editor that opened it.
|
|
12
12
|
const openEditorStack: object[] = [];
|
|
13
13
|
|
|
14
|
+
// Layers that open ABOVE an editor sheet and close on their own Escape: pickers
|
|
15
|
+
// (popovers), selects, menus, nested dialogs, the discard confirmation, and the
|
|
16
|
+
// rich-text editor's slash / mention menu. Our listener runs in the CAPTURE
|
|
17
|
+
// phase on `document`, so it sees Escape before any of them — if it handled the
|
|
18
|
+
// key it would stopPropagation() and close the whole sheet while the picker
|
|
19
|
+
// stayed open underneath. While one of these is open, Escape is theirs.
|
|
20
|
+
const INNER_LAYER_SELECTOR = [
|
|
21
|
+
"[data-slot=popover-content][data-open]",
|
|
22
|
+
"[data-slot=select-content][data-open]",
|
|
23
|
+
"[data-slot=combobox-content][data-open]",
|
|
24
|
+
"[data-slot=dropdown-menu-content][data-open]",
|
|
25
|
+
"[data-slot=context-menu-content][data-open]",
|
|
26
|
+
"[data-slot=dialog-content][data-open]",
|
|
27
|
+
"[data-slot=alert-dialog-content][data-open]",
|
|
28
|
+
".bn-suggestion-menu",
|
|
29
|
+
].join(",");
|
|
30
|
+
|
|
31
|
+
export function hasOpenInnerLayer(doc: Document = document): boolean {
|
|
32
|
+
return doc.querySelector(INNER_LAYER_SELECTOR) !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
type UseEditorDialogOptions = {
|
|
15
36
|
dialogOpen?: boolean;
|
|
16
37
|
onDialogOpenChange?: (open: boolean) => void;
|
|
@@ -116,6 +137,7 @@ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorD
|
|
|
116
137
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
117
138
|
if (event.key === "Escape" && open) {
|
|
118
139
|
if (openEditorStack[openEditorStack.length - 1] !== dialogId.current) return;
|
|
140
|
+
if (hasOpenInnerLayer()) return;
|
|
119
141
|
event.preventDefault();
|
|
120
142
|
event.stopPropagation();
|
|
121
143
|
handleOpenChange(false);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
|
-
import { Fragment, useState } from "react";
|
|
4
|
+
import { Fragment, useState, type MouseEvent } from "react";
|
|
5
5
|
import { ChevronDownIcon } from "lucide-react";
|
|
6
|
-
import { useIsMobile } from "../../utils";
|
|
6
|
+
import { cn, useIsMobile } from "../../utils";
|
|
7
7
|
import { usePageUrlGenerator } from "../../hooks";
|
|
8
8
|
import { BreadcrumbItemData } from "../../interfaces";
|
|
9
9
|
import {
|
|
@@ -147,24 +147,53 @@ function BreadcrumbMobile({
|
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// One entry means the menu could only offer the page already on screen, so
|
|
151
|
+
// the chevron promised a choice that does not exist: show plain text.
|
|
152
|
+
if (allItems.length <= 1) {
|
|
153
|
+
return (
|
|
154
|
+
<span className="text-foreground block min-w-0 truncate px-1.5 text-xs/relaxed font-normal">
|
|
155
|
+
{lastItem?.name}
|
|
156
|
+
</span>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
150
160
|
return (
|
|
151
161
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
162
|
+
{/* min-h-9: a 36px tap target, where the bare text row was ~20px. */}
|
|
163
|
+
<DropdownMenuTrigger className="text-foreground text-xs/relaxed font-normal hover:bg-accent flex min-h-9 min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 transition-colors outline-none">
|
|
164
|
+
<span className="truncate">{lastItem?.name}</span>
|
|
165
|
+
<ChevronDownIcon className="text-muted-foreground size-3.5 shrink-0" />
|
|
155
166
|
</DropdownMenuTrigger>
|
|
156
167
|
<DropdownMenuContent align="start">
|
|
157
|
-
{allItems.map((item, index) =>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
{allItems.map((item, index) =>
|
|
169
|
+
item.href ? (
|
|
170
|
+
// The item renders AS the link, so the whole row navigates — a Link
|
|
171
|
+
// nested inside the row only answered taps on its own text.
|
|
172
|
+
<DropdownMenuItem
|
|
173
|
+
key={index}
|
|
174
|
+
className="min-h-10"
|
|
175
|
+
render={(props) => (
|
|
176
|
+
<Link
|
|
177
|
+
{...props}
|
|
178
|
+
href={item.href!}
|
|
179
|
+
className={cn(props.className, "text-foreground font-normal")}
|
|
180
|
+
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
|
|
181
|
+
props.onClick?.(event);
|
|
182
|
+
item.onClick?.();
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{props.children}
|
|
186
|
+
</Link>
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{item.name}
|
|
190
|
+
</DropdownMenuItem>
|
|
191
|
+
) : (
|
|
192
|
+
<DropdownMenuItem key={index} className="min-h-10">
|
|
193
|
+
{item.name}
|
|
194
|
+
</DropdownMenuItem>
|
|
195
|
+
),
|
|
196
|
+
)}
|
|
168
197
|
</DropdownMenuContent>
|
|
169
198
|
</DropdownMenu>
|
|
170
199
|
);
|