From b13b0a9b9fbb20df8ec339871ba73449499f54bf Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 25 Jan 2026 21:33:25 +0100 Subject: [PATCH 01/14] Prettier --- packages/ui/spa/components/NavMenu/NavMenu.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/ui/spa/components/NavMenu/NavMenu.tsx b/packages/ui/spa/components/NavMenu/NavMenu.tsx index 32ca57cb..5c8d4452 100644 --- a/packages/ui/spa/components/NavMenu/NavMenu.tsx +++ b/packages/ui/spa/components/NavMenu/NavMenu.tsx @@ -32,11 +32,7 @@ export type NavMenuProps = { onAddPage?: (moduleFilePath: ModuleFilePath, urlPath: string) => void; }; -export function NavMenu({ - data, - isLoading = false, - onAddPage, -}: NavMenuProps) { +export function NavMenu({ data, isLoading = false, onAddPage }: NavMenuProps) { const { currentSourcePath, navigate } = useNavigation(); const layout = useLayout(); const { theme, setTheme } = useTheme(); From fec2c3880d21a1aae8ccc6d8a6b635b7336ef392 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 25 Jan 2026 21:38:07 +0100 Subject: [PATCH 02/14] Make s.route default for anchor elements in richtext --- packages/core/src/module.ts | 28 ++ packages/core/src/schema/deserialize.ts | 21 +- packages/core/src/schema/richtext.test.ts | 271 +++++++++++++++++- packages/core/src/schema/richtext.ts | 76 ++++- packages/core/src/source/richtext.ts | 12 +- .../src/internal/zod/SerializedSchema.ts | 59 ++-- packages/ui/spa/components/RichTextEditor.tsx | 2 +- 7 files changed, 425 insertions(+), 44 deletions(-) diff --git a/packages/core/src/module.ts b/packages/core/src/module.ts index cc16910a..5e6840ad 100644 --- a/packages/core/src/module.ts +++ b/packages/core/src/module.ts @@ -365,6 +365,20 @@ export function resolvePath< : resolvedSchema : resolvedSchema; } + if ( + "href" in resolvedSource && + "tag" in resolvedSource && + resolvedSource.tag === "a" && + parts.length === 0 + ) { + resolvedSchema = + resolvedSchema instanceof RichTextSchema + ? resolvedSchema["options"]?.inline?.a && + typeof resolvedSchema["options"]?.inline?.a !== "boolean" + ? resolvedSchema["options"].inline.a + : resolvedSchema + : resolvedSchema; + } resolvedSource = resolvedSource[part]; } else { throw Error( @@ -616,6 +630,20 @@ export function safeResolvePath< : resolvedSchema : resolvedSchema; } + if ( + "href" in resolvedSource && + "tag" in resolvedSource && + resolvedSource.tag === "a" && + parts.length === 0 + ) { + resolvedSchema = + resolvedSchema instanceof RichTextSchema + ? resolvedSchema["options"]?.inline?.a && + typeof resolvedSchema["options"]?.inline?.a !== "boolean" + ? resolvedSchema["options"].inline.a + : resolvedSchema + : resolvedSchema; + } resolvedSource = resolvedSource[part]; } else { return { diff --git a/packages/core/src/schema/deserialize.ts b/packages/core/src/schema/deserialize.ts index e574220e..1df84d46 100644 --- a/packages/core/src/schema/deserialize.ts +++ b/packages/core/src/schema/deserialize.ts @@ -70,12 +70,23 @@ export function deserializeSchema( const deserializedOptions = { ...(serialized.options || {}), inline: - typeof serialized.options?.inline?.img === "object" + typeof serialized.options?.inline?.img === "object" || + typeof serialized.options?.inline?.a === "object" ? { - a: serialized.options.inline.a, - img: deserializeSchema( - serialized.options.inline.img, - ) as ImageSchema>, + a: + typeof serialized.options?.inline?.a === "object" + ? (deserializeSchema(serialized.options.inline.a) as + | RouteSchema + | StringSchema) + : serialized.options?.inline?.a, + img: + typeof serialized.options?.inline?.img === "object" + ? (deserializeSchema( + serialized.options.inline.img, + ) as ImageSchema< + ImageSource | RemoteSource + >) + : serialized.options?.inline?.img, } : (serialized.options?.inline as | undefined diff --git a/packages/core/src/schema/richtext.test.ts b/packages/core/src/schema/richtext.test.ts index 86d0e8e7..2703897a 100644 --- a/packages/core/src/schema/richtext.test.ts +++ b/packages/core/src/schema/richtext.test.ts @@ -2,6 +2,8 @@ import { deepEqual } from "../patch"; import { AllRichTextOptions, RichTextSource } from "../source/richtext"; import { SourcePath } from "../val"; import { richtext } from "./richtext"; +import { route } from "./route"; +import { string } from "./string"; import { ValidationErrors } from "./validation/ValidationError"; describe("RichTextSchema", () => { @@ -30,7 +32,7 @@ describe("RichTextSchema", () => { tag: "p", children: [ "Visit ", - { tag: "a", href: "https://val.build", children: ["Val"] }, + { tag: "a", href: "/home", children: ["Val"] }, " for more information.", ], }, @@ -46,7 +48,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }); expectedErrorAtPaths( @@ -69,7 +71,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }) .minLength(1) @@ -94,7 +96,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }).minLength(100); expectedErrorAtPaths( @@ -117,7 +119,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }).maxLength(10); expectedErrorAtPaths( @@ -258,6 +260,265 @@ describe("RichTextSchema", () => { expect(errorAtPath).toHaveLength(1); } }); + + // Anchor href validation tests + test("validate: a: true with valid route href (green test)", () => { + const schema = richtext({ + inline: { + a: true, + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/blogs/blog1", children: ["Blog 1"] }, + " for more information.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Note: a: true now defaults to route validation, which returns router:check-route error + // This is expected and will be processed by Val internally + if (errors !== false) { + expect(Object.keys(errors).length).toBeGreaterThan(0); + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a: route() with explicit route schema", () => { + const schema = richtext({ + inline: { + a: route(), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/blogs/blog1", children: ["Blog 1"] }, + " for more.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Route validation returns router:check-route error for internal processing + if (errors !== false) { + expect(Object.keys(errors).length).toBeGreaterThan(0); + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a: string().maxLength(30) with valid short URL (green test)", () => { + const schema = richtext({ + inline: { + a: string().maxLength(30), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/short", children: ["Link"] }, + " here.", + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + [], + ); + }); + + test("validate: a: string().maxLength(30) with URL exceeding max length (red test)", () => { + const schema = richtext({ + inline: { + a: string().maxLength(30), + }, + }); + const input = [ + { + tag: "p", + children: [ + { + tag: "a", + href: "/this-is-a-very-long-url-that-exceeds-thirty-characters", + children: ["Link"], + }, + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + ['/richtext.val.ts?p=0."children".0."href"'], + ); + }); + + test("validate: a: route().include(/^\\/blog/) with matching route (green test)", () => { + const schema = richtext({ + inline: { + a: route().include(/^\/blog/), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/blog/post1", children: ["Blog"] }, + " here.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Route validation returns router:check-route error for internal processing + if (errors !== false) { + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a: route().exclude(/^\\/admin/) with excluded route (red test)", () => { + const schema = richtext({ + inline: { + a: route().exclude(/^\/admin/), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/admin/dashboard", children: ["Admin"] }, + " here.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Route validation returns router:check-route error for internal processing + if (errors !== false) { + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a tag without href attribute (red test)", () => { + const schema = richtext({ + inline: { + a: true, + }, + }); + const input = [ + { + tag: "p", + children: [ + { tag: "a", children: ["Link"] }, // Missing href + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + ['/richtext.val.ts?p=0."children".0'], + ); + }); + + test("validate: a: string() allows external URLs", () => { + const schema = richtext({ + inline: { + a: string(), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "https://example.com", children: ["External"] }, + " here.", + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + [], + ); + }); + + test("validate: a: string().regexp() validates URL patterns", () => { + const schema = richtext({ + inline: { + a: string().regexp(/^https?:\/\//), + }, + }); + const inputValid = [ + { + tag: "p", + children: [ + { tag: "a", href: "https://example.com", children: ["Link"] }, + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputValid as any, + ), + [], + ); + + const inputInvalid = [ + { + tag: "p", + children: [{ tag: "a", href: "/relative-url", children: ["Link"] }], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputInvalid as any, + ), + ['/richtext.val.ts?p=0."children".0."href"'], + ); + }); }); function expectedErrorAtPaths(errors: ValidationErrors, paths: string[]) { diff --git a/packages/core/src/schema/richtext.ts b/packages/core/src/schema/richtext.ts index e4b4f4d9..fd28085a 100644 --- a/packages/core/src/schema/richtext.ts +++ b/packages/core/src/schema/richtext.ts @@ -15,6 +15,8 @@ import { } from "../source/richtext"; import { SourcePath } from "../val"; import { ImageSchema, SerializedImageSchema } from "./image"; +import { RouteSchema, SerializedRouteSchema } from "./route"; +import { SerializedStringSchema, StringSchema } from "./string"; import { ValidationError, ValidationErrors, @@ -226,8 +228,59 @@ export class RichTextSchema< false, ); } - if (node.tag === "a" && !this.options.inline?.a) { - addError(path, `'a' inline is not valid`, false); + if (node.tag === "a") { + if (!this.options.inline?.a) { + addError(path, `'a' inline is not valid`, false); + } else if (this.options.inline?.a) { + if (!("href" in node)) { + return { + [path]: [ + { + message: `Expected 'href' in 'a'`, + typeError: true, + }, + ], + }; + } + const hrefPath = unsafeCreateSourcePath(path, "href"); + const hrefSchema = + typeof this.options.inline?.a === "boolean" + ? new RouteSchema() + : this.options.inline.a; + + const executeValidate = () => { + if (hrefSchema instanceof RouteSchema) { + return hrefSchema["executeValidate"]( + hrefPath, + node.href as string, + ); + } else if (hrefSchema instanceof StringSchema) { + return hrefSchema["executeValidate"]( + hrefPath, + node.href as string, + ); + } else { + const exhaustiveCheck: never = hrefSchema; + console.error( + "Exhaustive check failed in RichText (anchor href validation)", + exhaustiveCheck, + ); + return false; + } + }; + const hrefValidationErrors = executeValidate(); + if (hrefValidationErrors) { + for (const validationErrorPathS in hrefValidationErrors) { + const validationErrorPath = validationErrorPathS as SourcePath; + if (!current[validationErrorPath]) { + current[validationErrorPath] = []; + } + current[validationErrorPath].push( + ...hrefValidationErrors[validationErrorPath], + ); + } + } + } } if (node.tag === "img") { @@ -562,13 +615,30 @@ export class RichTextSchema< } protected executeSerialize(): SerializedSchema { + const serializeAnchorSchema = (): + | SerializedRouteSchema + | SerializedStringSchema + | boolean + | undefined => { + if (this.options.inline?.a instanceof RouteSchema) { + return this.options.inline?.a[ + "executeSerialize" + ]() as SerializedRouteSchema; + } else if (this.options.inline?.a instanceof StringSchema) { + return this.options.inline?.a[ + "executeSerialize" + ]() as SerializedStringSchema; + } else { + return this.options.inline?.a; + } + }; const serializedOptions: SerializedRichTextOptions & ValidationOptions = { maxLength: this.options.maxLength, minLength: this.options.minLength, style: this.options.style, block: this.options.block, inline: this.options.inline && { - a: this.options.inline.a, + a: serializeAnchorSchema(), img: this.options.inline.img && typeof this.options.inline.img === "object" ? (this.options.inline.img[ diff --git a/packages/core/src/source/richtext.ts b/packages/core/src/source/richtext.ts index 4584101e..d9dc2a65 100644 --- a/packages/core/src/source/richtext.ts +++ b/packages/core/src/source/richtext.ts @@ -3,6 +3,8 @@ import { ImageSchema, SerializedImageSchema, } from "../schema/image"; +import { RouteSchema, SerializedRouteSchema } from "../schema/route"; +import { StringSchema, SerializedStringSchema } from "../schema/string"; import { FileSource } from "./file"; import { ImageSource } from "./image"; import { RemoteSource } from "./remote"; @@ -26,7 +28,7 @@ export type RichTextOptions = Partial<{ // custom: Record>; }>; inline: Partial<{ - a: boolean; + a: boolean | RouteSchema | StringSchema; img: boolean | ImageSchema>; // custom: Record>; }>; @@ -50,7 +52,7 @@ export type SerializedRichTextOptions = Partial<{ // custom: Record>; }>; inline: Partial<{ - a: boolean; + a: boolean | SerializedRouteSchema | SerializedStringSchema; img: boolean | SerializedImageSchema; // custom: Record>; }>; @@ -155,7 +157,11 @@ export type LinkNode = NonNullable< O["inline"] >["a"] extends true ? LinkTagNode - : never; + : NonNullable["a"] extends + | RouteSchema + | StringSchema + ? LinkTagNode + : never; //#region List type ListItemTagNode = { diff --git a/packages/shared/src/internal/zod/SerializedSchema.ts b/packages/shared/src/internal/zod/SerializedSchema.ts index fe239003..7bcd8fd2 100644 --- a/packages/shared/src/internal/zod/SerializedSchema.ts +++ b/packages/shared/src/internal/zod/SerializedSchema.ts @@ -116,33 +116,38 @@ export const SerializedImageSchema: z.ZodType = opt: z.boolean(), }); -export const RichTextOptions: z.ZodType = z.object({ - style: z - .object({ - bold: z.boolean().optional(), - italic: z.boolean().optional(), - lineThrough: z.boolean().optional(), - }) - .optional(), - block: z - .object({ - h1: z.boolean().optional(), - h2: z.boolean().optional(), - h3: z.boolean().optional(), - h4: z.boolean().optional(), - h5: z.boolean().optional(), - h6: z.boolean().optional(), - ul: z.boolean().optional(), - ol: z.boolean().optional(), - }) - .optional(), - inline: z - .object({ - a: z.boolean().optional(), - img: z.union([z.boolean(), SerializedImageSchema]).optional(), - }) - .optional(), -}); +export const RichTextOptions: z.ZodType = z.lazy( + () => + z.object({ + style: z + .object({ + bold: z.boolean().optional(), + italic: z.boolean().optional(), + lineThrough: z.boolean().optional(), + }) + .optional(), + block: z + .object({ + h1: z.boolean().optional(), + h2: z.boolean().optional(), + h3: z.boolean().optional(), + h4: z.boolean().optional(), + h5: z.boolean().optional(), + h6: z.boolean().optional(), + ul: z.boolean().optional(), + ol: z.boolean().optional(), + }) + .optional(), + inline: z + .object({ + a: z + .union([z.boolean(), SerializedRouteSchema, SerializedStringSchema]) + .optional(), + img: z.union([z.boolean(), SerializedImageSchema]).optional(), + }) + .optional(), + }), +); export const SerializedRichTextSchema: z.ZodType = z.object({ type: z.literal("richtext"), diff --git a/packages/ui/spa/components/RichTextEditor.tsx b/packages/ui/spa/components/RichTextEditor.tsx index c4df7979..63ded9b2 100644 --- a/packages/ui/spa/components/RichTextEditor.tsx +++ b/packages/ui/spa/components/RichTextEditor.tsx @@ -345,7 +345,7 @@ const Toolbar = ({ } stroke={3} - isActive={options?.inline?.a || active.link()} + isActive={!!options?.inline?.a || active.link()} onToggle={() => chain .selectMark("link") From 0de08e19756a0d7281cd8aaa186cdeaa5fba8120 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 25 Jan 2026 21:38:41 +0100 Subject: [PATCH 03/14] Add explicit example with anchor element in richtext --- examples/next/app/blogs/[blog]/page.val.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts index a32bf7e5..23a2ee8a 100644 --- a/examples/next/app/blogs/[blog]/page.val.ts +++ b/examples/next/app/blogs/[blog]/page.val.ts @@ -4,7 +4,11 @@ import { linkSchema } from "../../../components/link.val"; const blogSchema = s.object({ title: s.string(), - content: s.richtext(), + content: s.richtext({ + inline: { + a: s.route(), + }, + }), author: s.keyOf(authorsVal), link: linkSchema, }); From b32271ccde2d548fd6464018b298e4696fd4e759 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 25 Jan 2026 22:00:11 +0100 Subject: [PATCH 04/14] Fix richtext styling and add story for richtext editor --- .../spa/components/RichTextEditor.stories.tsx | 460 ++++++++++++++++++ packages/ui/spa/components/RichTextEditor.tsx | 84 ++-- .../ui/spa/components/designSystem/button.tsx | 2 + 3 files changed, 509 insertions(+), 37 deletions(-) create mode 100644 packages/ui/spa/components/RichTextEditor.stories.tsx diff --git a/packages/ui/spa/components/RichTextEditor.stories.tsx b/packages/ui/spa/components/RichTextEditor.stories.tsx new file mode 100644 index 00000000..7dd74af1 --- /dev/null +++ b/packages/ui/spa/components/RichTextEditor.stories.tsx @@ -0,0 +1,460 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { RichTextEditor, useRichTextEditor } from "./RichTextEditor"; +import { RemirrorJSON } from "@remirror/core"; +import { initVal, RichTextSource, AllRichTextOptions } from "@valbuild/core"; +import { richTextToRemirror } from "@valbuild/shared/internal"; + +// Wrapper component that uses the hook +function RichTextEditorStory({ + content, + options, +}: { + content?: RichTextSource; + options?: { + style?: { + bold?: boolean; + italic?: boolean; + lineThrough?: boolean; + }; + inline?: { + a?: boolean; + img?: boolean; + }; + block?: { + h1?: boolean; + h2?: boolean; + h3?: boolean; + h4?: boolean; + h5?: boolean; + h6?: boolean; + ul?: boolean; + ol?: boolean; + }; + }; +}) { + const initialContent = content ? richTextToRemirror(content) : undefined; + const { manager, state, setState } = useRichTextEditor(initialContent); + + return ( +
+ { + setState(params.state); + }} + options={options} + autoFocus={false} + /> +
+ ); +} + +const meta: Meta = { + title: "Components/RichTextEditor", + component: RichTextEditorStory, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Empty editor +export const Empty: Story = { + args: { + content: [], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + ul: true, + ol: true, + }, + }, + }, + name: "Empty", +}; + +// Editor with a link +export const WithLink: Story = { + args: { + content: [ + { + tag: "p", + children: [ + "This is a paragraph with a ", + { + tag: "a", + href: "https://val.build", + children: ["link to Val"], + }, + " in the middle.", + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Link", +}; + +// Editor with a list +export const WithBulletList: Story = { + args: { + content: [ + { + tag: "p", + children: ["Here's a bullet list:"], + }, + { + tag: "ul", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["First item"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Second item"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Third item"], + }, + ], + }, + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Bullet List", +}; + +// Editor with an ordered list +export const WithOrderedList: Story = { + args: { + content: [ + { + tag: "p", + children: ["Here's an ordered list:"], + }, + { + tag: "ol", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["First step"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Second step"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Third step"], + }, + ], + }, + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Ordered List", +}; + +// Editor with text formatting (bold, italic, strikethrough) +export const WithFormatting: Story = { + args: { + content: [ + { + tag: "p", + children: [ + "This text is ", + { + tag: "span", + styles: ["bold"], + children: ["bold"], + }, + ", this is ", + { + tag: "span", + styles: ["italic"], + children: ["italic"], + }, + ", and this is ", + { + tag: "span", + styles: ["line-through"], + children: ["strikethrough"], + }, + ".", + ], + }, + { + tag: "p", + children: [ + "You can also combine them: ", + { + tag: "span", + styles: ["bold", "italic"], + children: ["bold and italic"], + }, + ".", + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Formatting", +}; + +// Comprehensive example with multiple features +export const Comprehensive: Story = { + args: { + content: [ + { + tag: "h1", + children: ["Rich Text Editor Demo"], + }, + { + tag: "p", + children: [ + "This editor supports ", + { + tag: "span", + styles: ["bold"], + children: ["bold"], + }, + ", ", + { + tag: "span", + styles: ["italic"], + children: ["italic"], + }, + ", and ", + { + tag: "span", + styles: ["line-through"], + children: ["strikethrough"], + }, + " text formatting.", + ], + }, + { + tag: "h2", + children: ["Links"], + }, + { + tag: "p", + children: [ + "You can also add ", + { + tag: "a", + href: "https://val.build", + children: ["hyperlinks"], + }, + " to your content.", + ], + }, + { + tag: "h2", + children: ["Lists"], + }, + { + tag: "ul", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["Bullet lists"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Are"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Supported"], + }, + ], + }, + ], + }, + { + tag: "ol", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["As well as"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Ordered lists"], + }, + ], + }, + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + ul: true, + ol: true, + }, + }, + }, + name: "Comprehensive Example", +}; diff --git a/packages/ui/spa/components/RichTextEditor.tsx b/packages/ui/spa/components/RichTextEditor.tsx index 63ded9b2..9a476084 100644 --- a/packages/ui/spa/components/RichTextEditor.tsx +++ b/packages/ui/spa/components/RichTextEditor.tsx @@ -31,6 +31,7 @@ import { DropdownMenuContent, DropdownMenuItem, } from "./designSystem/dropdown-menu"; +import { Button } from "./designSystem/button"; import { Internal, SerializedRichTextOptions } from "@valbuild/core"; import { readImage } from "../utils/readImage"; import { BoldExtension } from "@remirror/extension-bold"; @@ -187,14 +188,14 @@ const Toolbar = ({
-
+
{(options?.block?.h1 || options?.block?.h2 || options?.block?.h3 || @@ -203,16 +204,16 @@ const Toolbar = ({ options?.block?.h6 || active.heading()) && ( - - + } + icon={} stroke={3} isOption={options?.style?.bold} isActive={options?.style?.bold || active.bold()} @@ -309,7 +310,7 @@ const Toolbar = ({ )} {(options?.style?.lineThrough || active.strike()) && ( } + icon={} stroke={3} isOption={options?.style?.lineThrough} isActive={options?.style?.lineThrough || active.strike()} @@ -318,7 +319,7 @@ const Toolbar = ({ )} {(options?.style?.italic || active.italic()) && ( } + icon={} stroke={3} isOption={options?.style?.italic} isActive={options?.style?.italic || active.italic()} @@ -327,7 +328,7 @@ const Toolbar = ({ )} {(options?.block?.ul || active.bulletList()) && ( } + icon={} stroke={3} isActive={options?.block?.ul || active.bulletList()} onToggle={() => chain.toggleBulletList().focus().run()} @@ -335,7 +336,7 @@ const Toolbar = ({ )} {(options?.block?.ol || active.orderedList()) && ( } + icon={} stroke={3} isActive={options?.block?.ol || active.orderedList()} onToggle={() => chain.toggleOrderedList().focus().run()} @@ -343,7 +344,7 @@ const Toolbar = ({ )} {(options?.inline?.a || active.link()) && ( } + icon={} stroke={3} isActive={!!options?.inline?.a || active.link()} onToggle={() => @@ -356,10 +357,7 @@ const Toolbar = ({ /> )} {(options?.inline?.img || active.image()) && ( - + + )} {debug && ( - + )}
@@ -435,7 +441,7 @@ export function LinkToolBar() { return null; } return ( -
+
{ @@ -444,8 +450,9 @@ export function LinkToolBar() { defaultValue={href} placeholder="https://" > - - +
); } @@ -490,7 +498,9 @@ function ToolbarButton({ stroke: 2 | 3; }) { return ( - + ); } diff --git a/packages/ui/spa/components/designSystem/button.tsx b/packages/ui/spa/components/designSystem/button.tsx index 030857d5..b8bf6305 100644 --- a/packages/ui/spa/components/designSystem/button.tsx +++ b/packages/ui/spa/components/designSystem/button.tsx @@ -48,8 +48,10 @@ const buttonVariants = cva( size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", + xs: "h-8 rounded-md px-2", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", + "icon-sm": "h-8 w-8", }, }, defaultVariants: { From 4804daa8bdaf6f6514f0318cb82aebf1441452aa Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 25 Jan 2026 22:02:49 +0100 Subject: [PATCH 05/14] Fix missing caret color for richtext editor --- packages/ui/spa/index.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ui/spa/index.css b/packages/ui/spa/index.css index 4e32296a..45fb9e97 100644 --- a/packages/ui/spa/index.css +++ b/packages/ui/spa/index.css @@ -274,6 +274,14 @@ @layer components { .val-rich-text-editor .remirror-editor.ProseMirror { overflow-y: hidden; + min-height: 3rem; + caret-color: var(--fg-primary); + } + .val-rich-text-editor .remirror-editor.ProseMirror:focus { + caret-color: var(--fg-primary); + } + .val-rich-text-editor .remirror-editor.ProseMirror.ProseMirror-focused { + caret-color: var(--fg-primary); } .val-rich-text-editor h1 { @apply text-2xl font-bold; From cfe5a5dac1d611e1dd1690ca1a625856af026634 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Mon, 26 Jan 2026 22:36:06 +0100 Subject: [PATCH 06/14] Fix double clicking, inserting links in richtext editor --- packages/ui/spa/components/Field.tsx | 2 +- .../spa/components/RichTextEditor.stories.tsx | 212 ++++++++++++- packages/ui/spa/components/RichTextEditor.tsx | 292 ++++++++++++++---- .../ui/spa/components/ValFieldProvider.tsx | 14 + .../ui/spa/components/fields/RouteField.tsx | 187 ++++++----- packages/ui/spa/components/useKeysOf.ts | 3 +- .../ui/spa/components/useRouteReferences.ts | 3 +- packages/ui/spa/components/useRoutesOf.ts | 3 +- 8 files changed, 565 insertions(+), 151 deletions(-) diff --git a/packages/ui/spa/components/Field.tsx b/packages/ui/spa/components/Field.tsx index 03e61e99..a25a7118 100644 --- a/packages/ui/spa/components/Field.tsx +++ b/packages/ui/spa/components/Field.tsx @@ -7,9 +7,9 @@ import { useAddPatch, useSchemaAtPath, useShallowSourceAtPath, + useLoadingStatus, } from "./ValFieldProvider"; import { useValidationErrors } from "./ValErrorProvider"; -import { useLoadingStatus } from "./ValProvider"; import { Checkbox } from "./designSystem/checkbox"; import { JSONValue } from "@valbuild/core/patch"; import { ArrayAndRecordTools } from "./ArrayAndRecordTools"; diff --git a/packages/ui/spa/components/RichTextEditor.stories.tsx b/packages/ui/spa/components/RichTextEditor.stories.tsx index 7dd74af1..97581327 100644 --- a/packages/ui/spa/components/RichTextEditor.stories.tsx +++ b/packages/ui/spa/components/RichTextEditor.stories.tsx @@ -1,8 +1,130 @@ import type { Meta, StoryObj } from "@storybook/react"; import { RichTextEditor, useRichTextEditor } from "./RichTextEditor"; -import { RemirrorJSON } from "@remirror/core"; -import { initVal, RichTextSource, AllRichTextOptions } from "@valbuild/core"; +import { + RichTextSource, + AllRichTextOptions, + ModuleFilePath, + SerializedSchema, + ReifiedRender, + initVal, + Internal, + Json, +} from "@valbuild/core"; import { richTextToRemirror } from "@valbuild/shared/internal"; +import { ValPortalProvider } from "./ValPortalProvider"; +import { Themes, ValThemeProvider } from "./ValThemeProvider"; +import { useMemo, useState } from "react"; +import { ValFieldProvider } from "./ValFieldProvider"; +import { ValSyncEngine } from "../ValSyncEngine"; +import { ValClient } from "@valbuild/shared/internal"; +import { JSONValue } from "@valbuild/core/patch"; + +// Create mock data with routes for testing +function createMockData() { + const { s, c } = initVal(); + + // Create a simple mock router for Storybook + const mockRouter = { + getRouterId: () => "next-app-router", + validate: () => [], + }; + + // Router module for blog pages + const blogPages = c.define( + "/app/blogs/[blog]/page.val.ts", + s + .record( + s.object({ + title: s.string(), + content: s.richtext(), + }), + ) + .router(mockRouter), + { + "/blogs/getting-started": { + title: "Getting Started with Val", + content: [], + }, + "/blogs/advanced-features": { + title: "Advanced Features", + content: [], + }, + "/blogs/best-practices": { + title: "Best Practices", + content: [], + }, + }, + ); + + // Router module for docs pages + const docsPages = c.define( + "/app/docs/[...slug]/page.val.ts", + s + .record( + s.object({ + title: s.string(), + content: s.richtext(), + }), + ) + .router(mockRouter), + { + "/docs/introduction": { + title: "Introduction", + content: [], + }, + "/docs/installation": { + title: "Installation", + content: [], + }, + "/docs/api-reference": { + title: "API Reference", + content: [], + }, + }, + ); + + // Extract schemas and sources from the modules + const modules = [blogPages, docsPages]; + const schemas: Record = {}; + const sources: Record = {}; + const renders: Record = {}; + + for (const module of modules) { + const moduleFilePath = Internal.getValPath(module); + const schema = Internal.getSchema(module); + const source = Internal.getSource(module); + + if (moduleFilePath && schema && source !== undefined) { + const path = moduleFilePath as unknown as ModuleFilePath; + schemas[path] = schema["executeSerialize"](); + sources[path] = source; + renders[path] = schema["executeRender"](path, source); + } + } + + return { + schemas: schemas as Record, + sources: sources as Record, + renders: renders as Record, + }; +} + +// Create a minimal mock ValClient for Storybook +function createMockClient(mockData: { + schemas: Record; + sources: Record; +}): ValClient { + return (async () => { + return { + status: 200, + json: async () => ({ + schemas: mockData.schemas, + sources: mockData.sources, + config: { project: "storybook-test" }, + }), + } as unknown as Awaited>; + }) as ValClient; +} // Wrapper component that uses the hook function RichTextEditorStory({ @@ -34,8 +156,36 @@ function RichTextEditorStory({ }) { const initialContent = content ? richTextToRemirror(content) : undefined; const { manager, state, setState } = useRichTextEditor(initialContent); + const [theme, setTheme] = useState("dark"); - return ( + // Create mock data and sync engine if routes are needed + const mockData = useMemo(() => createMockData(), []); + const client = useMemo(() => createMockClient(mockData), [mockData]); + const syncEngine = useMemo(() => { + const engine = new ValSyncEngine(client, undefined); + engine.setSchemas(mockData.schemas); + engine.setSources( + mockData.sources as Record, + ); + engine.setRenders(mockData.renders); + engine.setInitializedAt(Date.now()); + return engine; + }, [client, mockData]); + + const getDirectFileUploadSettings = useMemo( + () => async () => { + return { + status: "success" as const, + data: { + nonce: null, + baseUrl: "https://mock-upload.example.com", + }, + }; + }, + [], + ); + + const editor = (
); + + return ( + + + + {editor} + + + + ); } const meta: Meta = { @@ -98,22 +268,52 @@ export const Empty: Story = { name: "Empty", }; -// Editor with a link +// Editor with links (both external and route links) export const WithLink: Story = { args: { content: [ { tag: "p", children: [ - "This is a paragraph with a ", + "This is a paragraph with an ", { tag: "a", href: "https://val.build", - children: ["link to Val"], + children: ["external link"], + }, + " and a ", + { + tag: "a", + href: "/blogs/getting-started", + children: ["route link"], }, " in the middle.", ], }, + { + tag: "p", + children: [ + "Here are more examples: ", + { + tag: "a", + href: "/blogs/advanced-features", + children: ["Advanced Features"], + }, + ", ", + { + tag: "a", + href: "/docs/introduction", + children: ["Introduction"], + }, + ", and ", + { + tag: "a", + href: "/docs/api-reference", + children: ["API Reference"], + }, + ".", + ], + }, ], options: { style: { diff --git a/packages/ui/spa/components/RichTextEditor.tsx b/packages/ui/spa/components/RichTextEditor.tsx index 9a476084..396317cb 100644 --- a/packages/ui/spa/components/RichTextEditor.tsx +++ b/packages/ui/spa/components/RichTextEditor.tsx @@ -5,6 +5,9 @@ import { useActive, useChainedCommands, useAttrs, + useCurrentSelection, + useRemirrorContext, + useHelpers, } from "@remirror/react"; import classNames from "classnames"; import { @@ -23,17 +26,25 @@ import { Image, Unlink, Check, + X, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "./designSystem/dropdown-menu"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "./designSystem/popover"; import { Button } from "./designSystem/button"; +import { Input } from "./designSystem/input"; import { Internal, SerializedRichTextOptions } from "@valbuild/core"; import { readImage } from "../utils/readImage"; +import { RouteSelector } from "./fields/RouteField"; import { BoldExtension } from "@remirror/extension-bold"; import { ItalicExtension } from "@remirror/extension-italic"; import { StrikeExtension } from "@remirror/extension-strike"; @@ -53,6 +64,8 @@ import { EditorState, RemirrorEventListenerProps, } from "@remirror/core"; +import { useValPortal } from "./ValPortalProvider"; +import { useRoutesWithModulePaths } from "./useRoutesOf"; const allExtensions = () => { const extensions = [ @@ -343,18 +356,7 @@ const Toolbar = ({ /> )} {(options?.inline?.a || active.link()) && ( - } - stroke={3} - isActive={!!options?.inline?.a || active.link()} - onToggle={() => - chain - .selectMark("link") - .updateLink({ href: "" }) - .focus() - .run() - } - /> + )} {(options?.inline?.img || active.image()) && ( <> @@ -413,69 +415,229 @@ const Toolbar = ({ )}
-
); }; -export function LinkToolBar() { +function LinkPopover({ options }: { options?: SerializedRichTextOptions }) { + const portalContainer = useValPortal(); const chain = useChainedCommands(); - const [href, setHref] = useState(); const activeLink = useAttrs().link(); + const { empty, to, from } = useCurrentSelection(); + + // Get all routes with their module paths for route selection + const routesWithModulePaths = useRoutesWithModulePaths(); + + const [open, setOpen] = useState(false); + const [linkText, setLinkText] = useState(""); + const [linkHref, setLinkHref] = useState(""); + + const { getTextBetween } = useHelpers(); + + // Detect if inline.a is a route schema + // When inline.a is true, default to route + // When inline.a is an object with type: "route", it's a route + // When inline.a is an object with type: "string", it's a string + const isRouteLink = + options?.inline?.a === true || + (typeof options?.inline?.a === "object" && + "type" in options.inline.a && + options.inline.a.type === "route"); + + // Extract patterns from schema if it's a route + const routeSchema = + isRouteLink && + options?.inline?.a && + typeof options.inline.a === "object" && + "type" in options.inline.a && + options.inline.a.type === "route" + ? options.inline.a + : undefined; + const includePattern = routeSchema?.options?.include + ? new RegExp( + routeSchema.options.include.source, + routeSchema.options.include.flags, + ) + : undefined; + const excludePattern = routeSchema?.options?.exclude + ? new RegExp( + routeSchema.options.exclude.source, + routeSchema.options.exclude.flags, + ) + : undefined; + + const { view } = useRemirrorContext(); + // Handle opening the popover + const handleOpenPopover = useCallback(() => { + // If cursor is on a link, always select the entire link first + const hasExistingLink = activeLink !== undefined; + if (hasExistingLink) { + // Select the entire link mark, whether or not there's already a selection + chain.selectMark("link").run(); + + // Get the href from activeLink + + // Small delay to let the selection update, then get the selected text + setTimeout(() => { + const selection = view.state.selection; + const selectedText = getTextBetween(selection.from, selection.to); + setLinkText(selectedText); + setLinkHref(activeLink.href as string); + setOpen(true); + }, 10); + } else { + setOpen(true); + } + }, [activeLink, chain, view, getTextBetween]); useEffect(() => { - const href = - typeof activeLink === "object" && - "href" in activeLink && - typeof activeLink.href === "string" - ? activeLink.href - : undefined; - setHref(href); - }, [activeLink]); + const handleDoubleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const linkElement = target.tagName === "A" ? target : target.closest("a"); + + if (linkElement) { + event.preventDefault(); + event.stopPropagation(); + const pos = view.posAtDOM(linkElement, 0); + chain.selectText(pos).selectMark("link").run(); + setLinkText(linkElement.textContent || ""); + setLinkHref(linkElement.getAttribute("href") || ""); + setTimeout(() => setOpen(true), 10); + } + }; + + const editorElement = view.dom; + editorElement.addEventListener("dblclick", handleDoubleClick); + return () => { + editorElement.removeEventListener("dblclick", handleDoubleClick); + }; + }, [view, chain]); + + const handleSubmit = () => { + if (!linkText.trim() || !linkHref.trim()) { + return; + } + + if (empty) { + chain + .insertText(linkText) + .selectText({ from, to: to + linkText.length }) + .updateLink({ href: linkHref }) + .focus() + .run(); + } else { + chain + .replaceText({ content: linkText }) + .updateLink({ href: linkHref }) + .focus() + .run(); + } + + setOpen(false); + setLinkText(""); + setLinkHref(""); + }; + + const handleRemove = () => { + chain.removeLink().focus().run(); + setOpen(false); + setLinkText(""); + setLinkHref(""); + }; + + const handleCancel = () => { + setOpen(false); + setLinkText(""); + setLinkHref(""); + }; + + const isSubmitDisabled = !linkText.trim() || !linkHref.trim(); - const isEnabled = - //active.link() || // doesn't seem to work for the first char (of a link) of a line, so we could remove this since selectedHref does the trick? - activeLink !== undefined; - if (!isEnabled) { - return null; - } return ( -
- { - setHref(ev.target.value); - }} - defaultValue={href} - placeholder="https://" - > - - -
+ + + + + +
+ +
+
+ + setLinkText(e.target.value)} + /> +
+
+ + {isRouteLink ? ( + setLinkHref(route)} + includePattern={includePattern} + excludePattern={excludePattern} + placeholder="Select route..." + portalContainer={portalContainer} + /> + ) : ( + setLinkHref(e.target.value)} + /> + )} +
+
+
+ {!empty && ( + + )} +
+
+ +
+
+
+
+
+
); } diff --git a/packages/ui/spa/components/ValFieldProvider.tsx b/packages/ui/spa/components/ValFieldProvider.tsx index d0189259..c3f32348 100644 --- a/packages/ui/spa/components/ValFieldProvider.tsx +++ b/packages/ui/spa/components/ValFieldProvider.tsx @@ -102,6 +102,20 @@ const useSyncEngineInitializedAt = (syncEngine: ValSyncEngine) => { return initializedAt.data; }; +export type LoadingStatus = "loading" | "not-asked" | "error" | "success"; +export function useLoadingStatus(): LoadingStatus { + const { syncEngine } = useContext(ValFieldContext); + const pendingOpsCount = useSyncExternalStore( + syncEngine.subscribe("pending-ops-count"), + () => syncEngine.getPendingOpsSnapshot(), + () => syncEngine.getPendingOpsSnapshot(), + ); + if (pendingOpsCount > 0) { + return "loading"; + } + return "success"; +} + const textEncoder = new TextEncoder(); const SavePatchFileResponse = z.object({ patchId: z.string().refine((v): v is PatchId => v.length > 0), diff --git a/packages/ui/spa/components/fields/RouteField.tsx b/packages/ui/spa/components/fields/RouteField.tsx index 24d58b78..770ba590 100644 --- a/packages/ui/spa/components/fields/RouteField.tsx +++ b/packages/ui/spa/components/fields/RouteField.tsx @@ -32,9 +32,102 @@ import { Link, Check, ChevronsUpDown } from "lucide-react"; import { ValidationErrors } from "../../components/ValidationError"; import { useRoutesWithModulePaths } from "../useRoutesOf"; +export interface RouteSelectorProps { + routes: Array<{ route: string; moduleFilePath: string }>; + value: string | null; + onChange: (route: string) => void; + includePattern?: RegExp; + excludePattern?: RegExp; + placeholder?: string; + className?: string; + portalContainer?: HTMLElement | null; + isLoading?: boolean; +} + +export function RouteSelector({ + routes, + value, + onChange, + includePattern, + excludePattern, + placeholder = "Select route...", + className, + portalContainer, + isLoading = false, +}: RouteSelectorProps) { + const [open, setOpen] = React.useState(false); + + // Filter routes based on include/exclude patterns + const filteredRoutes = routes.filter((routeInfo) => { + // If include pattern exists, route must match it + if (includePattern && !includePattern.test(routeInfo.route)) { + return false; + } + // If exclude pattern exists, route must NOT match it + if (excludePattern && excludePattern.test(routeInfo.route)) { + return false; + } + return true; + }); + + return ( + + + + + + + + + {isLoading ? ( +
Loading...
+ ) : filteredRoutes.length === 0 ? ( + No routes found. + ) : ( + + {filteredRoutes.map((routeInfo) => ( + { + onChange(currentValue); + setOpen(false); + }} + > + + {routeInfo.route} + + ))} + + )} +
+
+
+
+ ); +} + export function RouteField({ path }: { path: SourcePath }) { const type = "route"; - const [open, setOpen] = React.useState(false); const { navigate } = useNavigation(); const schemaAtPath = useSchemaAtPath(path); const sourceAtPath = useShallowSourceAtPath(path, type); @@ -94,18 +187,6 @@ export function RouteField({ path }: { path: SourcePath }) { ? new RegExp(schema.options.exclude.source, schema.options.exclude.flags) : undefined; - const filteredRoutes = routesWithModulePaths.filter((routeInfo) => { - // If include pattern exists, route must match it - if (includePattern && !includePattern.test(routeInfo.route)) { - return false; - } - // If exclude pattern exists, route must NOT match it - if (excludePattern && excludePattern.test(routeInfo.route)) { - return false; - } - return true; - }); - // Find the module path for the currently selected route const selectedRouteInfo = source ? routesWithModulePaths.find((r) => r.route === source) @@ -117,66 +198,26 @@ export function RouteField({ path }: { path: SourcePath }) {
- - - - - - - - - {isLoading ? ( -
Loading...
- ) : filteredRoutes.length === 0 ? ( - No routes found. - ) : ( - - {filteredRoutes.map((routeInfo) => ( - { - addPatch( - [ - { - op: "replace", - path: patchPath, - value: currentValue, - }, - ], - type, - ); - setOpen(false); - }} - > - - {routeInfo.route} - - ))} - - )} -
-
-
-
+ { + addPatch( + [ + { + op: "replace", + path: patchPath, + value: route, + }, + ], + type, + ); + }} + includePattern={includePattern} + excludePattern={excludePattern} + portalContainer={portalContainer} + isLoading={isLoading} + /> {source && selectedRouteInfo && ( - +
- + {anchorPosition && ( + +
+ + )} +
)} @@ -629,7 +666,6 @@ function LinkPopover({ options }: { options?: SerializedRichTextOptions }) { onClick={handleSubmit} disabled={isSubmitDisabled} > - {!empty ? "Update" : "Insert"}
diff --git a/packages/ui/spa/components/designSystem/popover.tsx b/packages/ui/spa/components/designSystem/popover.tsx index fe641707..c43eccd4 100644 --- a/packages/ui/spa/components/designSystem/popover.tsx +++ b/packages/ui/spa/components/designSystem/popover.tsx @@ -7,6 +7,8 @@ const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { @@ -28,4 +30,4 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/packages/ui/spa/components/fields/RouteField.tsx b/packages/ui/spa/components/fields/RouteField.tsx index 770ba590..2ad42f1c 100644 --- a/packages/ui/spa/components/fields/RouteField.tsx +++ b/packages/ui/spa/components/fields/RouteField.tsx @@ -42,6 +42,7 @@ export interface RouteSelectorProps { className?: string; portalContainer?: HTMLElement | null; isLoading?: boolean; + zIndex?: number; } export function RouteSelector({ @@ -54,6 +55,7 @@ export function RouteSelector({ className, portalContainer, isLoading = false, + zIndex, }: RouteSelectorProps) { const [open, setOpen] = React.useState(false); @@ -88,6 +90,9 @@ export function RouteSelector({ From 4b4456f5bbbd15f2221281a2c8b6d98f7da2ee0f Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 28 Jan 2026 16:34:15 +0000 Subject: [PATCH 09/14] Fixed search on home page --- packages/ui/spa/components/Module.tsx | 5 ++--- packages/ui/spa/components/Search.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/ui/spa/components/Module.tsx b/packages/ui/spa/components/Module.tsx index d0af49c3..6b91ce4d 100644 --- a/packages/ui/spa/components/Module.tsx +++ b/packages/ui/spa/components/Module.tsx @@ -165,12 +165,11 @@ export function Module({ path }: { path: SourcePath }) { function Home() { return ( -
-
+
+
-

Use the menu on the left to browse and edit your content.

diff --git a/packages/ui/spa/components/Search.tsx b/packages/ui/spa/components/Search.tsx index 38f03362..f90a3004 100644 --- a/packages/ui/spa/components/Search.tsx +++ b/packages/ui/spa/components/Search.tsx @@ -21,7 +21,7 @@ import { SearchResultsList, type SearchResult } from "./SearchResultsList"; import { getNavPathFromAll } from "./getNavPath"; import { useSearchWorker } from "../search/useSearchWorker"; -export function Search() { +export function Search({ container }: { container?: HTMLElement }) { const sources = useAllSources(); const schemasRes = useSchemas(); const schemas = schemasRes.status === "success" ? schemasRes.data : undefined; @@ -65,7 +65,7 @@ export function Search() { setOpen(true)}> - + Date: Wed, 28 Jan 2026 16:34:23 +0000 Subject: [PATCH 10/14] Fix router state not updating when navigating /val --- packages/ui/spa/components/ValRouter.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ui/spa/components/ValRouter.tsx b/packages/ui/spa/components/ValRouter.tsx index 1189c78c..1c4b4980 100644 --- a/packages/ui/spa/components/ValRouter.tsx +++ b/packages/ui/spa/components/ValRouter.tsx @@ -97,6 +97,13 @@ export function ValRouter({ }; execScroll(); } + } else if ( + location.pathname === "/val" || + location.pathname === "/val/" || + location.pathname === VAL_CONTENT_VIEW_ROUTE + ) { + // Handle the home route - reset to empty path + setSourcePath("" as SourcePath); } setReady(true); }; From d36c4bb5f1dc632b7027e70b448a2de64aeeba77 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 28 Jan 2026 17:20:27 +0100 Subject: [PATCH 11/14] Rm todo --- packages/ui/spa/components/ValPath.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/spa/components/ValPath.tsx b/packages/ui/spa/components/ValPath.tsx index 90d7af34..ed829b7e 100644 --- a/packages/ui/spa/components/ValPath.tsx +++ b/packages/ui/spa/components/ValPath.tsx @@ -154,8 +154,7 @@ export function ValPath({ Date: Wed, 28 Jan 2026 17:22:17 +0100 Subject: [PATCH 12/14] Fix resolve patch path so that ValPath resolves validation errors inside richtext correctly --- packages/ui/spa/resolvePatchPath.test.ts | 50 +++++++++++++++++++++ packages/ui/spa/resolvePatchPath.ts | 56 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/packages/ui/spa/resolvePatchPath.test.ts b/packages/ui/spa/resolvePatchPath.test.ts index e10f845a..831618bf 100644 --- a/packages/ui/spa/resolvePatchPath.test.ts +++ b/packages/ui/spa/resolvePatchPath.test.ts @@ -174,4 +174,54 @@ describe("resolvePatchPath", () => { source: "test", }); }); + + test("richtext anchor href", () => { + const richtextModule = c.define( + "/test-richtext.val.ts", + s.record( + s.object({ + text: s.richtext({ + style: { bold: true, italic: true, lineThrough: true }, + block: { h2: true, ul: true }, + inline: { a: true }, + }), + }), + ), + { + "/": { + text: [ + { + tag: "p", + children: [ + "Visit ", + { + tag: "a", + href: "https://val.build", + children: ["Val"], + }, + " for more information.", + ], + }, + ], + }, + }, + ); + const richtextSchema = + Internal.getSchema(richtextModule)!["executeSerialize"](); + const richtextSource = Internal.getSource(richtextModule); + + expect( + resolvePatchPath( + ["/", "text", "0", "children", "1", "href"], + richtextSchema, + richtextSource, + ), + ).toMatchObject({ + modulePath: `"/"."text"`, + schema: { + type: "richtext", + }, + source: "https://val.build", + }); + }); }); diff --git a/packages/ui/spa/resolvePatchPath.ts b/packages/ui/spa/resolvePatchPath.ts index eedc9f9c..cb592d56 100644 --- a/packages/ui/spa/resolvePatchPath.ts +++ b/packages/ui/spa/resolvePatchPath.ts @@ -7,6 +7,7 @@ import { SerializedObjectUnionSchema, SerializedStringUnionSchema, } from "@valbuild/core"; +import { isJsonArray } from "./utils/isJsonArray"; export function resolvePatchPath( patchPath: string[], @@ -263,6 +264,61 @@ export function resolvePatchPath( } currentSource = currentObjectSourceRes.source[part]; addPart(JSON.stringify(part)); + } else if (currentSchema.type === "richtext") { + for (const part of patchPath.slice(i)) { + i++; + if (currentSource === null) { + return { + success: false, + error: `Invalid source type in rich text: '${patchPath.join( + "/", + )}'. Expected an object but got 'null' at part ${i} (sliced: ${patchPath + .slice(0, i + 1) + .join("/")})`, + }; + } else if ( + typeof currentSource === "object" && + isJsonArray(currentSource) + ) { + if (!Number.isSafeInteger(Number(part))) { + return { + success: false, + error: `Invalid array index in rich text: '${patchPath.join( + "/", + )}'. Expected an integer but got '${part}' at part ${i} (sliced: ${patchPath + .slice(0, i + 1) + .join("/")})`, + }; + } + currentSource = currentSource[Number(part)]; + } else if ( + typeof currentSource === "object" && + !Array.isArray(currentSource) + ) { + currentSource = currentSource[part]; + } else if ( + typeof currentSource === "string" || + typeof currentSource === "number" || + typeof currentSource === "boolean" + ) { + return { + success: true, + modulePath: current as ModulePath, + schema: currentSchema, + source: currentSource, + allResolved, + }; + } else { + return { + success: false, + error: `Invalid source type in rich text: '${patchPath.join( + "/", + )}'. Expected an object but got '${typeof currentSource}' at part ${i} (sliced: ${patchPath + .slice(0, i + 1) + .join("/")})`, + }; + } + } } else { return { success: false, From 1705654810a6c4e482f2c91475aadfdcb3094d4f Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 28 Jan 2026 17:27:21 +0100 Subject: [PATCH 13/14] Lint --- packages/ui/spa/components/RichTextEditor.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui/spa/components/RichTextEditor.tsx b/packages/ui/spa/components/RichTextEditor.tsx index 1f21e072..1bf38ca4 100644 --- a/packages/ui/spa/components/RichTextEditor.tsx +++ b/packages/ui/spa/components/RichTextEditor.tsx @@ -24,8 +24,6 @@ import { Heading6, ListOrdered, Image, - Unlink, - Check, X, } from "lucide-react"; import { useEffect, useMemo, useRef, useState, useCallback } from "react"; From 3a5b2b7270406db7fdd4d19d1498c3e449efc338 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 28 Jan 2026 17:27:26 +0100 Subject: [PATCH 14/14] Prettier --- packages/ui/spa/components/useKeysOf.ts | 6 +++++- packages/ui/spa/components/useRouteReferences.ts | 6 +++++- packages/ui/spa/components/useRoutesOf.ts | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/ui/spa/components/useKeysOf.ts b/packages/ui/spa/components/useKeysOf.ts index 765bd92e..e4fd9c04 100644 --- a/packages/ui/spa/components/useKeysOf.ts +++ b/packages/ui/spa/components/useKeysOf.ts @@ -1,6 +1,10 @@ import { ModuleFilePath } from "@valbuild/core"; import { useMemo } from "react"; -import { useAllSources, useSchemas, useLoadingStatus } from "./ValFieldProvider"; +import { + useAllSources, + useSchemas, + useLoadingStatus, +} from "./ValFieldProvider"; import { getKeysOf } from "./getKeysOf"; export function useKeysOf( diff --git a/packages/ui/spa/components/useRouteReferences.ts b/packages/ui/spa/components/useRouteReferences.ts index e42a5d66..9a39427f 100644 --- a/packages/ui/spa/components/useRouteReferences.ts +++ b/packages/ui/spa/components/useRouteReferences.ts @@ -1,6 +1,10 @@ import { SourcePath } from "@valbuild/core"; import { useMemo } from "react"; -import { useAllSources, useSchemas, useLoadingStatus } from "./ValFieldProvider"; +import { + useAllSources, + useSchemas, + useLoadingStatus, +} from "./ValFieldProvider"; import { getRouteReferences } from "./getRouteReferences"; /** diff --git a/packages/ui/spa/components/useRoutesOf.ts b/packages/ui/spa/components/useRoutesOf.ts index 6f21ddd6..b0067984 100644 --- a/packages/ui/spa/components/useRoutesOf.ts +++ b/packages/ui/spa/components/useRoutesOf.ts @@ -1,5 +1,9 @@ import { useMemo } from "react"; -import { useAllSources, useSchemas, useLoadingStatus } from "./ValFieldProvider"; +import { + useAllSources, + useSchemas, + useLoadingStatus, +} from "./ValFieldProvider"; import { getRoutesOf, getRoutesWithModulePaths,