Extract affix URL UI component

This commit is contained in:
Zef Hemel
2026-07-23 16:42:20 +02:00
parent e0cd96ab10
commit 7b87420617
12 changed files with 221 additions and 96 deletions
+3 -5
View File
@@ -7,14 +7,11 @@ import {
Checkbox,
Input,
Select,
UrlPrefixInput,
} from "@silverbulletmd/silverbullet/ui";
import { adminApi, listUsers } from "../api.ts";
import { FolderPicker } from "../FolderPicker.tsx";
import {
FieldErrors,
UrlPrefixInput,
useSlugDefaults,
} from "../space_fields.tsx";
import { FieldErrors, useSlugDefaults } from "../space_fields.tsx";
import type { Binding, FieldError, SpaceInfo, UserInfo } from "../types.ts";
export function SpaceForm({
@@ -232,6 +229,7 @@ export function SpaceForm({
{bindType === "prefix" ? (
<UrlPrefixInput
id="space-bind-value"
origin={location.origin}
value={prefix}
onInput={setPrefix}
/>
@@ -1,7 +1,11 @@
import { Fragment } from "preact";
import { Button, Input } from "@silverbulletmd/silverbullet/ui";
import {
Button,
Input,
UrlPrefixInput,
} from "@silverbulletmd/silverbullet/ui";
import { FolderPicker } from "../../FolderPicker.tsx";
import { FieldErrors, UrlPrefixInput } from "../../space_fields.tsx";
import { FieldErrors } from "../../space_fields.tsx";
import type { FieldError } from "../../types.ts";
import {
defaultFolder,
@@ -84,6 +88,7 @@ export function SpaceStep({
<label for="setup-prefix">Prefix</label>
<UrlPrefixInput
id="setup-prefix"
origin={location.origin}
value={values.prefix}
onInput={onPrefixChange}
/>
-12
View File
@@ -1,12 +0,0 @@
/**
* Lower-cased, filesystem-safe version of a space name, used to derive its
* default folder and URL prefix. Shared by the space manager (`SpaceForm`) and
* the first-run setup wizard (`Wizard`) — both built from `client/spaces_ui` —
* so both derive identical defaults.
*/
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
+2 -25
View File
@@ -1,6 +1,5 @@
import { useState } from "preact/hooks";
import { Alert, Input } from "@silverbulletmd/silverbullet/ui";
import { slugify } from "./slugify.ts";
import { Alert, prefixFromName, slugify } from "@silverbulletmd/silverbullet/ui";
import type { FieldError } from "./types.ts";
/**
@@ -16,7 +15,7 @@ export function useSlugDefaults(folderTemplate: (slug: string) => string) {
function onNameChange(name: string) {
const slug = slugify(name);
if (!prefixTouched) setPrefix(slug ? `/${slug}` : "");
if (!prefixTouched) setPrefix(prefixFromName(name));
if (!folderTouched) setFolder(slug ? folderTemplate(slug) : "");
}
@@ -36,28 +35,6 @@ export function useSlugDefaults(folderTemplate: (slug: string) => string) {
};
}
/** The prefix field, decorated with the origin it will be served from. */
export function UrlPrefixInput({
id,
value,
onInput,
}: {
id: string;
value: string;
onInput: (v: string) => void;
}) {
return (
<div class="sb-url-input">
<span class="sb-url-affix">{location.origin}</span>
<Input
id={id}
value={value}
onInput={(e) => onInput(e.currentTarget.value)}
/>
</div>
);
}
/** Server-returned field errors, rendered the same way on every form. */
export function FieldErrors({ errors }: { errors: FieldError[] }) {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { slugify } from "./slugify.ts";
import { slugify } from "@silverbulletmd/silverbullet/ui";
import type { FieldError } from "./types.ts";
/** Where a space is bound: the server root, or a URL prefix under it. */
-50
View File
@@ -197,56 +197,6 @@ body.sb-standalone.sb-page {
flex: 1;
}
/* Binding-value input dressed up as the URL it will produce: fixed
scheme/host parts as affixes around the editable segment. Shared by the
setup wizard and the Space Manager's space form. */
// Matches .sb-input's own surface so the affixes read as decoration on one
// field rather than a differently-styled control sitting among the others.
.sb-standalone .sb-url-input {
display: flex;
align-items: stretch;
border: 0;
border-radius: 0.5em;
background: var(--text-field-background-color);
overflow: hidden;
}
.sb-standalone .sb-url-input:focus-within {
outline: 2px solid var(--ui-accent-color);
outline-offset: -1px;
}
.sb-standalone .sb-url-input .sb-input {
border: none;
border-radius: 0;
flex: 1;
min-width: 0;
width: auto;
background: transparent;
}
// The affixes are the parts you cannot type in, so they get their own darker
// surface — the editable segment keeps the plain field background. Without
// that contrast the whole control reads as one uniformly grey box and it stops
// being obvious which part is the input.
.sb-standalone .sb-url-affix {
display: flex;
align-items: center;
padding: 0.3em 0.6em;
background: var(--subtle-background-color);
color: var(--subtle-color);
font-size: 0.9em;
white-space: nowrap;
user-select: none;
}
.sb-standalone .sb-url-affix:first-child {
border-right: 1px solid #0002;
}
.sb-standalone .sb-url-affix:last-child {
border-left: 1px solid #0002;
}
/* Header: wordmark (+ admin tab bar) on the left, quiet log-out link on the
right.
+54 -1
View File
@@ -218,4 +218,57 @@
margin-left: 0.5em;
font-size: 0.85em;
opacity: 0.6;
}
}
/* Binding-value input dressed up as the URL it will produce: fixed
scheme/host parts as affixes around the editable segment. Backs the
`UrlPrefixInput` component, so it lives here rather than in the standalone
page styles — the desktop app's dashboard uses the component too, and only
links this stylesheet. */
// Matches .sb-input's own surface so the affixes read as decoration on one
// field rather than a differently-styled control sitting among the others.
.sb-url-input {
display: flex;
align-items: stretch;
border: 0;
border-radius: 0.5em;
background: var(--text-field-background-color);
overflow: hidden;
}
.sb-url-input:focus-within {
outline: 2px solid var(--ui-accent-color);
outline-offset: -1px;
}
.sb-url-input .sb-input {
border: none;
border-radius: 0;
flex: 1;
min-width: 0;
width: auto;
background: transparent;
}
// The affixes are the parts you cannot type in, so they get their own darker
// surface — the editable segment keeps the plain field background. Without
// that contrast the whole control reads as one uniformly grey box and it stops
// being obvious which part is the input.
.sb-url-affix {
display: flex;
align-items: center;
padding: 0.3em 0.6em;
background: var(--subtle-background-color);
color: var(--subtle-color);
font-size: 0.9em;
white-space: nowrap;
user-select: none;
}
.sb-url-affix:first-child {
border-right: 1px solid #0002;
}
.sb-url-affix:last-child {
border-left: 1px solid #0002;
}
+3
View File
@@ -14,6 +14,9 @@ export { Badge } from "./badge.tsx";
export type { BadgeProps } from "./badge.tsx";
export { Progress } from "./progress.tsx";
export type { ProgressProps } from "./progress.tsx";
export { UrlPrefixInput } from "./url_prefix_input.tsx";
export type { UrlPrefixInputProps } from "./url_prefix_input.tsx";
export { normalizePrefix, prefixFromName, slugify } from "./slugify.ts";
export { cx } from "./cx.ts";
export { panelStyles } from "./panel_styles.ts";
export type { PanelStylesOptions } from "./panel_styles.ts";
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "vitest";
import { normalizePrefix, prefixFromName, slugify } from "./slugify.ts";
test("slugify lowercases and collapses non-alphanumerics", () => {
expect(slugify("My Space!")).toBe("my-space");
expect(slugify(" Spaced Out ")).toBe("spaced-out");
expect(slugify("a---b")).toBe("a-b");
expect(slugify("!!!")).toBe("");
});
test("prefixFromName returns an absolute path", () => {
// The leading slash is part of the value: a binding prefix is a path, and
// the field renders it directly into the URL it shows.
expect(prefixFromName("My Space")).toBe("/my-space");
});
test("prefixFromName yields empty rather than a bare slash", () => {
// "/" is a *root* binding, a meaningfully different thing from "no prefix
// chosen yet" — defaulting an empty name to it would silently bind the
// space to the server root.
expect(prefixFromName("")).toBe("");
expect(prefixFromName(" ")).toBe("");
expect(prefixFromName("!!!")).toBe("");
});
test("normalizePrefix slugifies a value that already has a slash", () => {
// The desktop app's `provision_space` takes anything starting with "/"
// as-is, so the client is what has to guarantee a clean slug.
expect(normalizePrefix("/My Space!")).toBe("/my-space");
expect(normalizePrefix("My Space!")).toBe("/my-space");
});
test("normalizePrefix collapses repeated leading slashes", () => {
expect(normalizePrefix("//wiki")).toBe("/wiki");
expect(normalizePrefix("/wiki")).toBe("/wiki");
});
test("normalizePrefix maps a slash-only value to a root binding", () => {
expect(normalizePrefix("/")).toBe("");
expect(normalizePrefix("")).toBe("");
});
test("normalizePrefix is idempotent", () => {
const once = normalizePrefix("/My Space!");
expect(normalizePrefix(once)).toBe(once);
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Lower-cased, filesystem- and URL-safe version of a space name, used to
* derive its default folder and URL prefix.
*
* Mirrors the server's `slug::slugify`, so a prefix typed into the Space
* Manager, the first-run wizard, or the desktop app's space form all reach the
* server in the same shape.
*/
export function slugify(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* The default URL prefix for a space called `name`, as an absolute path.
*
* The leading slash is part of the value, not decoration: a binding prefix is
* a path, the server stores and validates it that way, and showing it in the
* field is what makes the assembled URL in `UrlPrefixInput` read correctly. An
* empty name yields `""` rather than a bare `"/"`, which would bind the space
* to the server root.
*/
export function prefixFromName(name: string): string {
const slug = slugify(name);
return slug ? `/${slug}` : "";
}
/**
* Force a user-typed prefix into the shape the server accepts: exactly one
* leading slash, and a slugified body.
*
* Callers that hand a prefix straight to an API need this because a value
* already starting with `/` is taken as-is by the desktop app's
* `provision_space` command — it only slugifies input that lacks the slash, so
* `/My Space!` would otherwise be sent through verbatim. Returns `""` for an
* empty or slash-only input, the server's spelling of a root binding.
*/
export function normalizePrefix(raw: string): string {
const slug = slugify(raw.replace(/^\/+/, ""));
return slug ? `/${slug}` : "";
}
+29
View File
@@ -10,6 +10,7 @@ import {
Progress,
Select,
Tabs,
UrlPrefixInput,
} from "./index.ts";
test("Button primary emits both classes and merges consumer class", () => {
@@ -90,3 +91,31 @@ test("Progress clamps value to a width percentage", () => {
expect(render(h(Progress, { value: 2 }))).toMatch(/width:\s*100%/);
expect(render(h(Progress, { value: -1 }))).toMatch(/width:\s*0%/);
});
test("UrlPrefixInput shows the origin it is given, not the ambient one", () => {
// The desktop app configures a *remote* sync server, so the origin cannot
// be read from `location` the way the server-hosted Space Manager does.
const html = render(
h(UrlPrefixInput, {
origin: "https://sb.example.com",
value: "/notes",
onInput: () => {},
}),
);
expect(html).toContain("sb-url-input");
expect(html).toContain(">https://sb.example.com</span>");
expect(html).toContain('value="/notes"');
});
test("UrlPrefixInput trims a trailing slash off the origin", () => {
// Sync server URLs are commonly stored with one; without trimming the
// assembled URL reads "https://h//notes".
const html = render(
h(UrlPrefixInput, {
origin: "https://sb.example.com/",
value: "/notes",
onInput: () => {},
}),
);
expect(html).toContain(">https://sb.example.com</span>");
});
+32
View File
@@ -0,0 +1,32 @@
import { Input } from "./input.tsx";
export type UrlPrefixInputProps = {
id?: string;
origin: string;
value: string;
onInput: (v: string) => void;
};
/**
* The URL-prefix field, dressed up as the URL it will produce: a fixed origin
* affix followed by the editable path segment.
*/
export function UrlPrefixInput({
id,
origin,
value,
onInput,
}: UrlPrefixInputProps) {
return (
<div class="sb-url-input">
<span class="sb-url-affix">{origin.replace(/\/+$/, "")}</span>
<Input
id={id}
value={value}
onInput={(e) => onInput(e.currentTarget.value)}
spellcheck={false}
autocomplete="off"
/>
</div>
);
}