@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.
Files changed (80) hide show
  1. package/dist/{BlockNoteEditor-HKS6AXDA.js → BlockNoteEditor-4EIKMNY3.js} +20 -10
  2. package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
  3. package/dist/{BlockNoteEditor-46O3JC4Z.mjs → BlockNoteEditor-AXYVDDXM.mjs} +13 -3
  4. package/dist/{BlockNoteEditor-46O3JC4Z.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
  5. package/dist/billing/index.js +335 -335
  6. package/dist/billing/index.mjs +1 -1
  7. package/dist/{chunk-Y3OKEQGL.js → chunk-ABVTYQIJ.js} +578 -363
  8. package/dist/chunk-ABVTYQIJ.js.map +1 -0
  9. package/dist/{chunk-KLV6RLDE.js → chunk-GKDHYB2R.js} +2 -2
  10. package/dist/chunk-GKDHYB2R.js.map +1 -0
  11. package/dist/{chunk-XDYILJ2S.mjs → chunk-QWZAGQWJ.mjs} +546 -331
  12. package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
  13. package/dist/{chunk-4Z2UONN2.mjs → chunk-U65JPKO3.mjs} +2 -2
  14. package/dist/chunk-U65JPKO3.mjs.map +1 -0
  15. package/dist/client/index.js +2 -2
  16. package/dist/client/index.mjs +1 -1
  17. package/dist/components/index.d.mts +16 -3
  18. package/dist/components/index.d.ts +16 -3
  19. package/dist/components/index.js +4 -2
  20. package/dist/components/index.js.map +1 -1
  21. package/dist/components/index.mjs +3 -1
  22. package/dist/contexts/index.js +2 -2
  23. package/dist/contexts/index.mjs +1 -1
  24. package/dist/features/analytics/index.js +100 -100
  25. package/dist/features/analytics/index.js.map +1 -1
  26. package/dist/features/analytics/index.mjs +5 -5
  27. package/dist/features/analytics/index.mjs.map +1 -1
  28. package/dist/features/help/index.js +36 -36
  29. package/dist/features/help/index.js.map +1 -1
  30. package/dist/features/help/index.mjs +6 -6
  31. package/dist/features/help/index.mjs.map +1 -1
  32. package/dist/features/tokenusage/index.js +103 -103
  33. package/dist/features/tokenusage/index.js.map +1 -1
  34. package/dist/features/tokenusage/index.mjs +4 -4
  35. package/dist/features/tokenusage/index.mjs.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/containers/PageContainer.tsx +1 -1
  38. package/src/components/containers/RoundPageContainer.tsx +60 -16
  39. package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
  40. package/src/components/editors/BlockNoteEditor.tsx +13 -1
  41. package/src/components/forms/EntityMultiSelector.tsx +4 -4
  42. package/src/components/forms/EntitySelector.tsx +4 -4
  43. package/src/components/forms/FormBlockNote.tsx +21 -1
  44. package/src/components/forms/FormCheckbox.tsx +6 -2
  45. package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
  46. package/src/components/forms/useEditorDialog.ts +22 -0
  47. package/src/components/navigations/Breadcrumb.tsx +45 -16
  48. package/src/components/navigations/Header.tsx +16 -3
  49. package/src/components/navigations/MobileNavigationBar.tsx +12 -0
  50. package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
  51. package/src/components/tables/ContentListTable.tsx +52 -32
  52. package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +9 -6
  53. package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +2 -1
  54. package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
  55. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  56. package/src/features/assistant-message/components/MessageItem.tsx +2 -2
  57. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
  58. package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
  59. package/src/features/company/components/details/CompanyContent.tsx +3 -1
  60. package/src/features/company/components/details/CompanyDetails.tsx +9 -1
  61. package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
  62. package/src/features/help/components/HelpHeader.tsx +7 -5
  63. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
  64. package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
  65. package/src/features/tokenusage/lib/formatters.ts +5 -1
  66. package/src/shadcnui/ui/button.tsx +1 -1
  67. package/src/shadcnui/ui/checkbox.tsx +1 -1
  68. package/src/shadcnui/ui/command.tsx +4 -4
  69. package/src/shadcnui/ui/dialog.tsx +2 -0
  70. package/src/shadcnui/ui/input.tsx +1 -1
  71. package/src/shadcnui/ui/popover.tsx +9 -0
  72. package/src/shadcnui/ui/select.tsx +1 -1
  73. package/src/shadcnui/ui/sheet.tsx +8 -1
  74. package/src/shadcnui/ui/table.tsx +2 -2
  75. package/src/shadcnui/ui/textarea.tsx +1 -1
  76. package/dist/BlockNoteEditor-HKS6AXDA.js.map +0 -1
  77. package/dist/chunk-4Z2UONN2.mjs.map +0 -1
  78. package/dist/chunk-KLV6RLDE.js.map +0 -1
  79. package/dist/chunk-XDYILJ2S.mjs.map +0 -1
  80. 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 { useHeaderChildren, useHeaderLeftContent, useHeaderLogo, useHeaderMobileChildren } from "@/contexts";
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
- <div className="flex w-full flex-col">
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`, clip)}>
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(`flex min-w-0 grow data-[orientation=horizontal]:flex-row`, isFixed && `h-full`, clip)}
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`, clip)}>
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-48 px-2 pt-2 md:hidden">{railHeader}</div>}
463
- {/* Section Select — below md */}
464
- <div data-testid="round-page-rail-select" className="p-2 md:hidden">
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`, clip) : cn(scrollY, `p-4`))}
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`, clip) : scrollY,
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` : scrollY,
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={showDetails} onOpenChange={setShowDetails}>
609
- <SheetContent side="end">
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
- <div className={cn(`flex w-full flex-row items-center gap-x-2 p-4 justify-between`, HEADER_ROW_MIN_H)}>
44
- {!isMobile ? (
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
- <div className="text-muted-foreground flex min-w-0 items-center gap-x-2 text-base font-light">
55
- {title.titleActions}
56
- {module && module.icon ? <module.icon className="text-primary h-5 w-5 shrink-0" /> : title.icon}
57
- <span className="truncate">{title.type}</span>
58
- </div>
59
- )}
60
- {(title.functions || details) && (
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
- "[&_.bn-default-styles]:!text-sm",
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
- field.onChange(content);
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
- <FieldLabel htmlFor={id} className={`font-normal ${labelBefore ? "" : "ms-3"}`}>
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
- <DropdownMenuTrigger className="text-foreground text-xs/relaxed font-normal hover:bg-accent flex items-center gap-1 rounded-md px-1.5 py-0.5 transition-colors outline-none">
153
- {lastItem?.name}
154
- <ChevronDownIcon className="text-muted-foreground size-3.5" />
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
- <DropdownMenuItem key={index}>
159
- {item.href ? (
160
- <Link href={item.href} onClick={item.onClick}>
161
- {item.name}
162
- </Link>
163
- ) : (
164
- <>{item.name}</>
165
- )}
166
- </DropdownMenuItem>
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
  );