bsmnt 0.11.0 → 0.12.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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/modules/features/cms/sanity/files/components/ui/sanity-image/index.tsx +36 -4
  3. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/components/rich-text.tsx +7 -1
  4. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +7 -1
  5. package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +36 -4
  6. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +26 -7
  7. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +242 -0
  8. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +62 -0
  9. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +72 -34
  10. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
  11. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/studio-preview.ts +37 -0
  12. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +3 -0
  13. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +2 -0
  14. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +5 -0
  15. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +7 -1
  16. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/ui/sanity-image/index.tsx +36 -4
  17. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-input.tsx +26 -7
  18. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +242 -0
  19. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +62 -0
  20. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +72 -34
  21. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
  22. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/studio-preview.ts +37 -0
  23. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +3 -0
  24. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +2 -0
  25. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +5 -0
  26. package/src/templates/next-default/.biome/plugins/no-fill-without-sizes.grit +11 -0
  27. package/src/templates/next-default/biome.json +2 -1
  28. package/src/templates/next-default/components/ui/image/index.tsx +20 -12
  29. package/src/templates/next-default/next.config.ts +10 -4
  30. package/src/templates/next-experiments/.biome/plugins/no-fill-without-sizes.grit +11 -0
  31. package/src/templates/next-experiments/biome.json +2 -1
  32. package/src/templates/next-experiments/components/ui/image/index.tsx +20 -12
  33. package/src/templates/next-experiments/next.config.ts +10 -4
  34. package/src/templates/next-pagebuilder/.biome/plugins/no-fill-without-sizes.grit +11 -0
  35. package/src/templates/next-pagebuilder/biome.json +2 -1
  36. package/src/templates/next-pagebuilder/components/page-builder/components/article-content/index.tsx +5 -0
  37. package/src/templates/next-pagebuilder/components/page-builder/components/article-content/related-post-item.tsx +1 -0
  38. package/src/templates/next-pagebuilder/components/ui/image/index.tsx +20 -12
  39. package/src/templates/next-pagebuilder/next.config.ts +4 -3
  40. package/src/templates/next-pagebuilder-cache/.biome/plugins/no-fill-without-sizes.grit +11 -0
  41. package/src/templates/next-pagebuilder-cache/biome.json +2 -1
  42. package/src/templates/next-pagebuilder-cache/components/page-builder/components/article-content/index.tsx +5 -0
  43. package/src/templates/next-pagebuilder-cache/components/page-builder/components/article-content/related-post-item.tsx +1 -0
  44. package/src/templates/next-pagebuilder-cache/components/ui/image/index.tsx +21 -12
  45. package/src/templates/next-pagebuilder-cache/lib/utils/image-sizes.ts +72 -0
  46. package/src/templates/next-pagebuilder-cache/next.config.ts +4 -3
  47. package/src/templates/next-webgl/.biome/plugins/no-fill-without-sizes.grit +11 -0
  48. package/src/templates/next-webgl/biome.json +2 -1
  49. package/src/templates/next-webgl/components/ui/image/index.tsx +20 -12
  50. package/src/templates/next-webgl/next.config.ts +10 -4
@@ -9,15 +9,35 @@ export type PageBuilderDocumentType =
9
9
  | "blogContent"
10
10
  | (typeof singletonComponents)[number]["schemaType"];
11
11
 
12
+ /**
13
+ * A block declares two orthogonal axes. `pageTypes` is which page documents may
14
+ * host it and drives validation; `category` is what the block structurally is
15
+ * and drives the insert menu. Never collapse them back into one field — that is
16
+ * what made the menu a single flat list.
17
+ */
18
+ export type PageBuilderGroupName = "generic" | "blogEntry";
19
+
20
+ export const componentCategories = [
21
+ { name: "hero", title: "Hero" },
22
+ { name: "text", title: "Text" },
23
+ { name: "list", title: "List" },
24
+ { name: "content", title: "Content" },
25
+ { name: "misc", title: "Miscellaneous" },
26
+ ] as const satisfies ReadonlyArray<{ name: string; title: string }>;
27
+
28
+ export type ComponentCategory = (typeof componentCategories)[number]["name"];
29
+
12
30
  type PageBuilderReferenceMember = {
13
31
  documentType: PageBuilderDocumentType;
14
32
  name: string;
15
33
  title: string;
16
34
  documentId?: string;
17
- groups: PageBuilderGroupName[];
35
+ pageTypes: readonly PageBuilderGroupName[];
36
+ category: ComponentCategory;
18
37
  };
19
38
 
20
- export type PageBuilderGroupName = "generic" | "blogEntry";
39
+ export const blockPreviewKey = (schemaTypeName: string) =>
40
+ `block.${schemaTypeName}`;
21
41
 
22
42
  export const pageBuilderPageTypes: Array<{
23
43
  title: string;
@@ -41,31 +61,36 @@ const reusablePageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
41
61
  documentType: "hero",
42
62
  name: "heroReference",
43
63
  title: "Heading",
44
- groups: ["generic"],
64
+ pageTypes: ["generic"],
65
+ category: "hero",
45
66
  },
46
67
  {
47
68
  documentType: "description",
48
69
  name: "descriptionReference",
49
70
  title: "Description",
50
- groups: ["generic"],
71
+ pageTypes: ["generic"],
72
+ category: "text",
51
73
  },
52
74
  {
53
75
  documentType: "richTextBlock",
54
76
  name: "richTextBlockReference",
55
77
  title: "Rich Text",
56
- groups: ["generic"],
78
+ pageTypes: ["generic"],
79
+ category: "text",
57
80
  },
58
81
  {
59
82
  documentType: "gatedContent",
60
83
  name: "gatedContentReference",
61
84
  title: "Gated Content",
62
- groups: ["generic"],
85
+ pageTypes: ["generic"],
86
+ category: "misc",
63
87
  },
64
88
  {
65
89
  documentType: "blogContent",
66
90
  name: "blogContentReference",
67
91
  title: "Blog Content",
68
- groups: ["blogEntry"],
92
+ pageTypes: ["blogEntry"],
93
+ category: "content",
69
94
  },
70
95
  ];
71
96
 
@@ -75,7 +100,8 @@ const singletonPageBuilderReferenceMembers: PageBuilderReferenceMember[] =
75
100
  name: `${component.schemaType}Reference`,
76
101
  title: component.title,
77
102
  documentId: component.documentId,
78
- groups: ["generic"],
103
+ pageTypes: ["generic"],
104
+ category: component.category,
79
105
  }));
80
106
 
81
107
  export const pageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
@@ -93,25 +119,37 @@ export const getPageBuilderReferenceMemberByDocumentType = (
93
119
  (member) => member.documentType === documentType,
94
120
  );
95
121
 
96
- const isPageBuilderGroupVisible = (
122
+ const isMemberVisibleForPageType = (
123
+ member: PageBuilderReferenceMember,
97
124
  pageType: PageBuilderGroupName | undefined,
98
- group: PageBuilderGroupName,
99
- ) => (pageType ?? "generic") === group;
125
+ ) => member.pageTypes.includes(pageType ?? "generic");
100
126
 
101
127
  export const getVisiblePageBuilderReferenceMembers = (
102
128
  pageType: PageBuilderGroupName | undefined,
103
129
  ) =>
104
130
  pageBuilderReferenceMembers.filter((member) =>
105
- member.groups.some((group) => isPageBuilderGroupVisible(pageType, group)),
131
+ isMemberVisibleForPageType(member, pageType),
106
132
  );
107
133
 
108
- export const getVisiblePageBuilderInsertMenuGroups = (
134
+ export const getVisiblePageBuilderMemberNames = (
109
135
  pageType: PageBuilderGroupName | undefined,
110
136
  ) =>
111
- pageBuilderInsertMenuGroups.filter(
112
- (group) => group.name === (pageType ?? "generic"),
137
+ new Set(
138
+ getVisiblePageBuilderReferenceMembers(pageType).map((member) => member.name),
113
139
  );
114
140
 
141
+ export const getVisiblePageBuilderInsertMenuGroups = (
142
+ pageType: PageBuilderGroupName | undefined,
143
+ ) => {
144
+ const visible = getVisiblePageBuilderMemberNames(pageType);
145
+ return pageBuilderInsertMenuGroups
146
+ .map((group) => ({
147
+ ...group,
148
+ of: group.of.filter((name) => visible.has(name)),
149
+ }))
150
+ .filter((group) => group.of.length > 0);
151
+ };
152
+
115
153
  export const getInvalidPageBuilderDocumentTypes = (
116
154
  pageType: PageBuilderGroupName | undefined,
117
155
  documentTypes: PageBuilderDocumentType[],
@@ -121,24 +159,24 @@ export const getInvalidPageBuilderDocumentTypes = (
121
159
 
122
160
  if (!member) return false;
123
161
 
124
- return !member.groups.some((group) =>
125
- isPageBuilderGroupVisible(pageType, group),
126
- );
162
+ return !isMemberVisibleForPageType(member, pageType);
127
163
  });
128
164
 
129
- export const pageBuilderInsertMenuGroups = [
130
- {
131
- name: "generic",
132
- title: "Generic",
133
- of: pageBuilderReferenceMembers
134
- .filter((member) => member.groups.includes("generic"))
135
- .map((member) => member.name),
136
- },
137
- {
138
- name: "blogEntry",
139
- title: "Blog Entry",
140
- of: pageBuilderReferenceMembers
141
- .filter((member) => member.groups.includes("blogEntry"))
142
- .map((member) => member.name),
143
- },
144
- ];
165
+ export const pageBuilderInsertMenuGroups: Array<{
166
+ name: ComponentCategory;
167
+ title: string;
168
+ of: string[];
169
+ }> = componentCategories.map(({ name, title }) => ({
170
+ name,
171
+ title,
172
+ of: pageBuilderReferenceMembers
173
+ .filter((member) => member.category === name)
174
+ .map((member) => member.name),
175
+ }));
176
+
177
+ export const pageBuilderBlockPreviewOptions = pageBuilderReferenceMembers.map(
178
+ (member) => ({
179
+ title: `Block — ${member.title} · ${member.name}`,
180
+ value: blockPreviewKey(member.name),
181
+ }),
182
+ );
@@ -4,3 +4,4 @@ export { gatedContent } from "./reusable/gated-content";
4
4
  export { hero } from "./reusable/hero";
5
5
  export { richTextBlock } from "./reusable/rich-text-block";
6
6
  export { blogCollection } from "./singleton/content-collection";
7
+ export { studioPreview } from "./studio-preview";
@@ -0,0 +1,37 @@
1
+ import { defineField, defineType } from "sanity";
2
+ import { pageBuilderCardIcon } from "@/lib/integrations/sanity/icons";
3
+ import { pageBuilderBlockPreviewOptions } from "@/lib/integrations/sanity/page-builder-config";
4
+
5
+ export const studioPreview = defineType({
6
+ name: "studioPreview",
7
+ title: "Component Preview",
8
+ type: "document",
9
+ icon: pageBuilderCardIcon,
10
+ fields: [
11
+ defineField({
12
+ name: "key",
13
+ title: "Component",
14
+ type: "string",
15
+ options: { list: pageBuilderBlockPreviewOptions },
16
+ validation: (Rule) => Rule.required(),
17
+ }),
18
+ defineField({
19
+ name: "preview",
20
+ title: "Preview image",
21
+ type: "image",
22
+ options: { hotspot: true },
23
+ validation: (Rule) => Rule.required(),
24
+ }),
25
+ ],
26
+ preview: {
27
+ select: { key: "key", media: "preview" },
28
+ prepare({ key, media }) {
29
+ return {
30
+ title:
31
+ pageBuilderBlockPreviewOptions.find((option) => option.value === key)
32
+ ?.title ?? "Component Preview",
33
+ media,
34
+ };
35
+ },
36
+ },
37
+ });
@@ -15,6 +15,7 @@ import {
15
15
  gatedContent,
16
16
  hero,
17
17
  richTextBlock,
18
+ studioPreview,
18
19
  } from "./components";
19
20
  import { author } from "./content/author";
20
21
  import { blogCategory } from "./content/blog-category";
@@ -77,6 +78,7 @@ export {
77
78
  richText,
78
79
  softwareApplicationJsonLd,
79
80
  structuredData,
81
+ studioPreview,
80
82
  videoJsonLd,
81
83
  };
82
84
 
@@ -113,6 +115,7 @@ export const schema: { types: SchemaTypeDefinition[] } = {
113
115
  description,
114
116
  richTextBlock,
115
117
  gatedContent,
118
+ studioPreview,
116
119
 
117
120
  // Layout types (singletons for site-wide layout)
118
121
  companyData,
@@ -6,12 +6,14 @@ import {
6
6
  structuredDataIcon,
7
7
  } from "./icons";
8
8
 
9
+ // `category` must be one of `componentCategories` in page-builder-config.ts.
9
10
  export const singletonComponents = [
10
11
  {
11
12
  schemaType: "blogCollection",
12
13
  documentId: "blogCollection",
13
14
  title: "Blog Collection",
14
15
  icon: pageListIcon,
16
+ category: "list",
15
17
  },
16
18
  ] as const;
17
19
 
@@ -17,6 +17,7 @@ import {
17
17
  gatedContentIcon,
18
18
  heroIcon,
19
19
  layoutFolderIcon,
20
+ pageBuilderCardIcon,
20
21
  pageFolderIcon,
21
22
  pageIcon,
22
23
  pagesFolderIcon,
@@ -301,6 +302,10 @@ export const structure: StructureResolver = (S, context) =>
301
302
  S.documentTypeListItem("blogContent")
302
303
  .title("Blog Content")
303
304
  .icon(contentIcon),
305
+ S.divider(),
306
+ S.documentTypeListItem("studioPreview")
307
+ .title("Component Previews")
308
+ .icon(pageBuilderCardIcon),
304
309
  ]),
305
310
  ),
306
311
 
@@ -42,7 +42,13 @@ export const RichText = ({ content }: RichTextProps) => {
42
42
  value={content}
43
43
  components={{
44
44
  types: {
45
- image: ({ value }) => <SanityImage image={value} maxWidth={1920} />,
45
+ image: ({ value }) => (
46
+ <SanityImage
47
+ image={value}
48
+ maxWidth={1920}
49
+ sizes="(min-width: 768px) 768px, 100vw"
50
+ />
51
+ ),
46
52
  table: ({ value }) => {
47
53
  const rows = value?.rows as
48
54
  | Array<{ _key?: string; cells?: string[] }>
@@ -2,7 +2,8 @@ import { getImageDimensions } from "@sanity/asset-utils";
2
2
  import { Image, type ImageProps } from "@/components/ui/image";
3
3
  import { urlForImage } from "@/lib/integrations/sanity/utils/image";
4
4
 
5
- interface SanityImageProps extends Omit<ImageProps, "src" | "aspectRatio"> {
5
+ interface SanityImageBaseProps
6
+ extends Omit<ImageProps, "src" | "aspectRatio" | "sizes" | "width" | "height"> {
6
7
  image: {
7
8
  asset?: {
8
9
  _ref: string;
@@ -15,6 +16,38 @@ interface SanityImageProps extends Omit<ImageProps, "src" | "aspectRatio"> {
15
16
  maxWidth?: number;
16
17
  }
17
18
 
19
+ type SanityImageProps = SanityImageBaseProps &
20
+ (
21
+ | { sizes: string; width?: never; height?: never }
22
+ | { width: number; height: number; sizes?: string }
23
+ );
24
+
25
+ const MAX_SOURCE_WIDTH = 3840;
26
+
27
+ const isGifRef = (ref: string) => ref.endsWith("-gif");
28
+
29
+ const getImageSrc = (
30
+ image: SanityImageProps["image"],
31
+ maxWidth: number,
32
+ croppedWidth: number,
33
+ ) => {
34
+ const builder = urlForImage(image);
35
+ const ref = image.asset?._ref ?? "";
36
+
37
+ if (ref.endsWith("-svg")) return builder.url();
38
+
39
+ const requestWidth = Math.min(maxWidth, MAX_SOURCE_WIDTH, croppedWidth);
40
+ const { left, right, top, bottom } = image.crop ?? {};
41
+ const hasCrop = Boolean(left || right || top || bottom);
42
+ const isGif = isGifRef(ref);
43
+
44
+ if (!(isGif || hasCrop) && requestWidth >= croppedWidth) {
45
+ return builder.url();
46
+ }
47
+
48
+ return builder.width(requestWidth).url();
49
+ };
50
+
18
51
  export function SanityImage({
19
52
  image,
20
53
  maxWidth = 1920,
@@ -39,11 +72,10 @@ export function SanityImage({
39
72
 
40
73
  return (
41
74
  <Image
42
- src={urlForImage(image)
43
- .width(Math.min(maxWidth, sourceWidth))
44
- .url()}
75
+ src={getImageSrc(image, maxWidth, sourceWidth)}
45
76
  alt={alt || image.alt || ""}
46
77
  aspectRatio={sourceWidth / sourceHeight}
78
+ unoptimized={isGifRef(image.asset._ref)}
47
79
  {...imageProps}
48
80
  {...props}
49
81
  />
@@ -1,25 +1,43 @@
1
1
  import { type ArrayOfObjectsInputProps, useFormValue } from "sanity";
2
+ import { PageBuilderInsertMenu } from "@/lib/integrations/sanity/components/page-builder-insert-menu";
3
+ import { useStudioPreviewUrls } from "@/lib/integrations/sanity/components/use-studio-preview-urls";
2
4
  import {
5
+ blockPreviewKey,
3
6
  getVisiblePageBuilderInsertMenuGroups,
4
- getVisiblePageBuilderReferenceMembers,
7
+ getVisiblePageBuilderMemberNames,
5
8
  type PageBuilderGroupName,
9
+ pageBuilderReferenceMembers,
6
10
  } from "@/lib/integrations/sanity/page-builder-config";
7
11
 
12
+ const BLOCK_PREVIEW_KEYS = pageBuilderReferenceMembers.map((member) =>
13
+ blockPreviewKey(member.name),
14
+ );
15
+
16
+ function useInsertMenuViews() {
17
+ const previewUrls = useStudioPreviewUrls(BLOCK_PREVIEW_KEYS);
18
+
19
+ return [
20
+ {
21
+ name: "grid" as const,
22
+ previewImageUrl: (schemaTypeName: string) =>
23
+ previewUrls?.[blockPreviewKey(schemaTypeName)],
24
+ },
25
+ { name: "list" as const },
26
+ ];
27
+ }
28
+
8
29
  export function PageBuilderInput(props: ArrayOfObjectsInputProps) {
9
30
  const pageTypeValue = useFormValue(["type"]);
10
31
  const pageType =
11
32
  typeof pageTypeValue === "string"
12
33
  ? (pageTypeValue as PageBuilderGroupName)
13
34
  : undefined;
14
-
15
- const visibleMemberNames = new Set(
16
- getVisiblePageBuilderReferenceMembers(pageType).map(
17
- (member) => member.name,
18
- ),
19
- );
35
+ const views = useInsertMenuViews();
36
+ const visibleMemberNames = getVisiblePageBuilderMemberNames(pageType);
20
37
 
21
38
  return props.renderDefault({
22
39
  ...props,
40
+ arrayFunctions: PageBuilderInsertMenu,
23
41
  schemaType: {
24
42
  ...props.schemaType,
25
43
  of: props.schemaType.of.filter(
@@ -32,6 +50,7 @@ export function PageBuilderInput(props: ArrayOfObjectsInputProps) {
32
50
  insertMenu: {
33
51
  ...props.schemaType.options?.insertMenu,
34
52
  groups: getVisiblePageBuilderInsertMenuGroups(pageType),
53
+ views,
35
54
  },
36
55
  },
37
56
  },
@@ -0,0 +1,242 @@
1
+ import { AddIcon, SearchIcon } from "@sanity/icons";
2
+ import {
3
+ Box,
4
+ Button,
5
+ Card,
6
+ Dialog,
7
+ Flex,
8
+ Grid,
9
+ Stack,
10
+ Text,
11
+ TextInput,
12
+ } from "@sanity/ui";
13
+ import { useMemo, useState } from "react";
14
+ import type { ArrayInputFunctionsProps, ArraySchemaType } from "sanity";
15
+
16
+ type ArrayItem = { _key: string };
17
+ type MemberSchemaType = ArraySchemaType["of"][number];
18
+
19
+ type CreateItem = (memberType: MemberSchemaType) => ArrayItem;
20
+
21
+ type InsertMenuSection = {
22
+ name: string;
23
+ title: string;
24
+ members: MemberSchemaType[];
25
+ };
26
+
27
+ const SCROLL_HEIGHT = "min(60vh, 34rem)";
28
+ const TILE_ASPECT_RATIO = "3 / 2";
29
+
30
+ const memberTitle = (member: MemberSchemaType) => member.title ?? member.name;
31
+
32
+ function BlockTile({
33
+ member,
34
+ previewUrl,
35
+ onSelect,
36
+ }: {
37
+ member: MemberSchemaType;
38
+ previewUrl: string | undefined;
39
+ onSelect: () => void;
40
+ }) {
41
+ const Icon = member.icon;
42
+
43
+ return (
44
+ <Card
45
+ as="button"
46
+ border
47
+ onClick={onSelect}
48
+ overflow="hidden"
49
+ padding={0}
50
+ radius={2}
51
+ style={{ cursor: "pointer", textAlign: "left", width: "100%" }}
52
+ type="button"
53
+ >
54
+ <Stack space={0}>
55
+ <Box
56
+ style={{
57
+ aspectRatio: TILE_ASPECT_RATIO,
58
+ background: "var(--card-muted-bg-color)",
59
+ position: "relative",
60
+ }}
61
+ >
62
+ {previewUrl ? (
63
+ // biome-ignore lint/performance/noImgElement: Studio-only CMS asset
64
+ <img
65
+ alt=""
66
+ src={`${previewUrl}?w=640&fit=max&auto=format`}
67
+ style={{
68
+ height: "100%",
69
+ inset: 0,
70
+ objectFit: "contain",
71
+ position: "absolute",
72
+ width: "100%",
73
+ }}
74
+ />
75
+ ) : (
76
+ <Flex
77
+ align="center"
78
+ justify="center"
79
+ style={{ inset: 0, position: "absolute" }}
80
+ >
81
+ {Icon ? <Icon /> : null}
82
+ </Flex>
83
+ )}
84
+ </Box>
85
+ <Box padding={2}>
86
+ <Text size={1} textOverflow="ellipsis" weight="medium">
87
+ {memberTitle(member)}
88
+ </Text>
89
+ </Box>
90
+ </Stack>
91
+ </Card>
92
+ );
93
+ }
94
+
95
+ function InsertMenuDialog({
96
+ sections,
97
+ previewImageUrl,
98
+ onSelect,
99
+ onClose,
100
+ }: {
101
+ sections: InsertMenuSection[];
102
+ previewImageUrl: ((schemaTypeName: string) => string | undefined) | undefined;
103
+ onSelect: (member: MemberSchemaType) => void;
104
+ onClose: () => void;
105
+ }) {
106
+ const [search, setSearch] = useState("");
107
+
108
+ const visibleSections = useMemo(() => {
109
+ const query = search.trim().toLowerCase();
110
+ if (!query) return sections;
111
+ return sections
112
+ .map((section) => ({
113
+ ...section,
114
+ members: section.members.filter((member) =>
115
+ memberTitle(member).toLowerCase().includes(query),
116
+ ),
117
+ }))
118
+ .filter((section) => section.members.length > 0);
119
+ }, [search, sections]);
120
+
121
+ return (
122
+ <Dialog
123
+ header="Components"
124
+ id="page-builder-insert"
125
+ onClose={onClose}
126
+ open
127
+ width={3}
128
+ >
129
+ <Stack padding={4} space={4}>
130
+ <TextInput
131
+ autoFocus
132
+ icon={SearchIcon}
133
+ onChange={(event) => setSearch(event.currentTarget.value)}
134
+ placeholder="Search components…"
135
+ value={search}
136
+ />
137
+ <Box style={{ height: SCROLL_HEIGHT, overflowY: "auto" }}>
138
+ {visibleSections.length === 0 ? (
139
+ <Text muted size={1}>
140
+ No components match “{search}”.
141
+ </Text>
142
+ ) : (
143
+ <Stack space={5}>
144
+ {visibleSections.map((section) => (
145
+ <Stack key={section.name} space={3}>
146
+ <Text muted size={1} weight="semibold">
147
+ {section.title}
148
+ </Text>
149
+ <Grid columns={[2, 3, 4]} gap={3}>
150
+ {section.members.map((member) => (
151
+ <BlockTile
152
+ key={member.name}
153
+ member={member}
154
+ onSelect={() => onSelect(member)}
155
+ previewUrl={previewImageUrl?.(member.name)}
156
+ />
157
+ ))}
158
+ </Grid>
159
+ </Stack>
160
+ ))}
161
+ </Stack>
162
+ )}
163
+ </Box>
164
+ </Stack>
165
+ </Dialog>
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Pass as `arrayFunctions`. Reads `insertMenu.groups` and `insertMenu.views`, so
171
+ * a schema already configuring the built-in menu needs no extra wiring.
172
+ *
173
+ * Replaces the built-in popover, which is anchored to the array grid element and
174
+ * so clips on page scroll and resizes when switching tabs.
175
+ */
176
+ export function PageBuilderInsertMenu(
177
+ props: ArrayInputFunctionsProps<ArrayItem, ArraySchemaType>,
178
+ ) {
179
+ const { onItemAppend, onValueCreate, readOnly, schemaType } = props;
180
+ const [open, setOpen] = useState(false);
181
+
182
+ const insertMenu = schemaType.options?.insertMenu;
183
+ const members = schemaType.of;
184
+
185
+ const sections = useMemo<InsertMenuSection[]>(() => {
186
+ const byName = new Map(members.map((member) => [member.name, member]));
187
+ const groups = insertMenu?.groups;
188
+ if (!groups) {
189
+ return [{ name: "all", title: "Components", members: [...members] }];
190
+ }
191
+ return groups
192
+ .map((group) => ({
193
+ name: group.name,
194
+ title: group.title ?? group.name,
195
+ members: (group.of ?? [])
196
+ .map((name) => byName.get(name))
197
+ .filter((member) => member !== undefined),
198
+ }))
199
+ .filter((section) => section.members.length > 0);
200
+ }, [members, insertMenu]);
201
+
202
+ const previewImageUrl = useMemo(() => {
203
+ const gridView = insertMenu?.views?.find((view) => view.name === "grid");
204
+ return gridView && "previewImageUrl" in gridView
205
+ ? gridView.previewImageUrl
206
+ : undefined;
207
+ }, [insertMenu]);
208
+
209
+ const insert = (member: MemberSchemaType) => {
210
+ onItemAppend((onValueCreate as unknown as CreateItem)(member));
211
+ setOpen(false);
212
+ };
213
+
214
+ const onlyMember = members.length === 1 ? members[0] : undefined;
215
+
216
+ if (schemaType.options?.disableActions?.includes("add")) return null;
217
+
218
+ return (
219
+ <>
220
+ <Grid>
221
+ <Button
222
+ disabled={readOnly === true}
223
+ fontSize={1}
224
+ icon={AddIcon}
225
+ mode="ghost"
226
+ onClick={() => (onlyMember ? insert(onlyMember) : setOpen(true))}
227
+ padding={3}
228
+ text={onlyMember ? "Add item" : "Add item…"}
229
+ />
230
+ </Grid>
231
+
232
+ {open && (
233
+ <InsertMenuDialog
234
+ onClose={() => setOpen(false)}
235
+ onSelect={insert}
236
+ previewImageUrl={previewImageUrl}
237
+ sections={sections}
238
+ />
239
+ )}
240
+ </>
241
+ );
242
+ }