diff --git a/web/src/lib/components/ui/Input.stories.svelte b/web/src/lib/components/ui/Input.stories.svelte index f5890741..7ea5dd5b 100644 --- a/web/src/lib/components/ui/Input.stories.svelte +++ b/web/src/lib/components/ui/Input.stories.svelte @@ -21,6 +21,10 @@ disabled: { control: "boolean" }, loading: { control: "boolean" }, rich: { control: "boolean" }, + commitOn: { + control: { type: "inline-check" }, + options: ["enter", "space", "comma"] + }, placeholder: { control: "text" } }, args: { @@ -40,6 +44,9 @@ { field: "author", value: "eti", color: "info" }, { field: "label", value: "needs-review", color: "warning" } ]); + let entered = $state([{ value: "svelte" }]); + let spaced = $state([{ value: "motion" }]); + let phrases = $state([{ value: "needs a review" }]); let reviewers = $state([{ value: "eti.tf" }]); let capped = $state([{ value: "one" }, { value: "two" }]); @@ -92,8 +99,8 @@ - +
+
+
+ + + +
+ + +
diff --git a/web/src/lib/components/ui/Input.svelte b/web/src/lib/components/ui/Input.svelte index 710be619..99cb99cb 100644 --- a/web/src/lib/components/ui/Input.svelte +++ b/web/src/lib/components/ui/Input.svelte @@ -3,7 +3,12 @@ // re-exporting them from here trips eslint's no-import-assign on module scripts import type { Component, Snippet } from "svelte"; import type { HTMLInputAttributes, SvelteHTMLElements } from "svelte/elements"; - import { inputField, type InputFieldVariants, type InputTag } from "./inputField"; + import { + inputField, + type InputFieldVariants, + type InputTag, + type TagCommitKey + } from "./inputField"; import Spinner from "./Spinner.svelte"; import TagInput from "./TagInput.svelte"; @@ -27,7 +32,9 @@ tags?: InputTag[]; /** hard ceiling on tags; native `max` still reaches the plain input */ maxTags?: number; - separators?: string[]; + /** what commits the draft: Enter, a typed space, a typed comma, or any mix. Defaults to + * Enter alone. Whatever's in here splits a paste too. */ + commitOn?: TagCommitKey[]; allowDuplicates?: boolean; /** vet a candidate before it lands; return a string to say why it didn't */ validate?: (value: string) => boolean | string; @@ -62,7 +69,7 @@ rich = false, tags = $bindable([]), maxTags, - separators, + commitOn, allowDuplicates, validate, suggestions, @@ -93,7 +100,7 @@ {loading} {iconLeft} {maxTags} - {separators} + {commitOn} {allowDuplicates} {validate} {suggestions} diff --git a/web/src/lib/components/ui/TagInput.svelte b/web/src/lib/components/ui/TagInput.svelte index d5abd35c..80a097ed 100644 --- a/web/src/lib/components/ui/TagInput.svelte +++ b/web/src/lib/components/ui/TagInput.svelte @@ -20,9 +20,9 @@ field: "shrink-0 opacity-70", label: "truncate", remove: - "-mr-0.5 flex shrink-0 p-0 min-h-0 rounded-xs text-foreground-inherit transition-colors focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-border-focus", + "text-foreground-inherit -mr-0.5 flex min-h-0 shrink-0 rounded-xs p-0 transition-colors focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-border-focus", draft: - "w-full min-w-0 bg-transparent outline-none placeholder:text-foreground-placeholder disabled:cursor-not-allowed", + "w-full min-w-0 bg-transparent outline-none placeholder:text-foreground-placeholder placeholder:transition-opacity placeholder:duration-200 disabled:cursor-not-allowed motion-reduce:placeholder:transition-none", panel: "tag-panel fixed z-50 m-0 hidden max-h-[min(18rem,calc(100dvh-1rem))] w-72 max-w-[calc(100vw-1rem)] popover-transition flex-col overflow-hidden rounded-sm border border-border-default bg-background-default p-0 text-foreground-default open:flex", options: "min-h-0 flex-1 overflow-y-auto p-1", @@ -60,10 +60,18 @@ footer: "typography-paragraph-small", panel: "typography-paragraph-regular" } + }, + // once a chip is in the row it says what the field is for better than the placeholder + // does, so the placeholder gets out of the way — fading, since the chip it makes room + // for arrives on a spring rather than appearing + hasTags: { + true: { draft: "placeholder:opacity-0" }, + false: {} } }, defaultVariants: { - size: "regular" + size: "regular", + hasTags: false } }); @@ -93,8 +101,10 @@ import { Spring, prefersReducedMotion } from "svelte/motion"; import { inputField, + TAG_COMMIT_CHARS, type InputFieldVariants, type InputTag, + type TagCommitKey, type TagRejection } from "$lib/components/ui/inputField"; import Spinner from "$lib/components/ui/Spinner.svelte"; @@ -114,8 +124,9 @@ /** hard ceiling; shows a counter and turns further drafts away. Not `max`, which stays the * native numeric attribute the plain rendering forwards. */ maxTags?: number; - /** characters that commit the draft as they're typed, and split a paste */ - separators?: string[]; + /** what commits the draft: hitting Enter, typing a space, typing a comma, or any mix of + * the three. Whatever's in here splits a paste too. */ + commitOn?: TagCommitKey[]; allowDuplicates?: boolean; /** vet a candidate before it lands; return a string to say why it didn't */ validate?: (value: string) => boolean | string; @@ -149,7 +160,7 @@ loading = false, iconLeft, maxTags, - separators = [","], + commitOn = ["enter"], allowDuplicates = false, validate, suggestions = [], @@ -230,13 +241,17 @@ }); const box = $derived(inputField({ size, error, disabled, rich: true, class: className })); - const slots = $derived(tagInput({ size })); + const slots = $derived(tagInput({ size, hasTags: tags.length > 0 })); const iconSize = $derived(size === "large" ? "size-5" : "size-4"); const glyphSize = $derived(size === "large" ? "size-3.5" : "size-3"); - const splitter = $derived( - new RegExp(`[${separators.map((s) => s.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")).join("")}]`) - ); + // Enter is a key, so the keydown handler takes it; space and comma arrive as text, so they're + // caught as the draft changes. Both readings come off the same list. + const commitsOnEnter = $derived(commitOn.includes("enter")); + const separators = $derived(commitOn.map((key) => TAG_COMMIT_CHARS[key])); + // newline, space and comma all stand for themselves inside a character class, so there is + // nothing to escape — and an empty list gives `[]`, which matches nothing, as it should + const splitter = $derived(new RegExp(`[${separators.join("")}]`)); const clean = (raw: string) => raw.trim().replace(/\s+/g, " "); const labelOf = (tag: InputTag) => tag.label ?? tag.value; @@ -370,11 +385,18 @@ move(-1); break; case "Enter": { - // a bare Enter in a form would submit it, whether or not anything commits - event.preventDefault(); const option = open ? visible[activeIndex] : undefined; - if (option) pick(option); - else if (add(value)) setDraft(""); + // taking the highlighted suggestion is what the panel is there for, no matter what + // commits a typed draft + if (option) { + event.preventDefault(); + pick(option); + } else if (commitsOnEnter) { + // a bare Enter in a form would submit it, whether or not the draft lands + event.preventDefault(); + if (add(value)) setDraft(""); + } + // otherwise Enter isn't ours: let the form have it break; } case "Backspace": diff --git a/web/src/lib/components/ui/inputField.ts b/web/src/lib/components/ui/inputField.ts index 905215a7..6645b80c 100644 --- a/web/src/lib/components/ui/inputField.ts +++ b/web/src/lib/components/ui/inputField.ts @@ -19,6 +19,18 @@ export interface InputTag { /** why a draft was turned away: the footer says which, and a duplicate flashes the chip it hit */ export type TagRejection = "duplicate" | "limit" | "invalid"; +/** what turns a draft into a chip. Pick any mix: `space` suits one-word tags, `comma` suits + * values that can hold spaces, `enter` is the safe default that never eats a keystroke. */ +export type TagCommitKey = "enter" | "space" | "comma"; + +/** the character each key stands for. Typing one commits, and a pasted one splits — which is why + * `enter` maps to a newline: you can't type one into a single-line input, but you can paste one. */ +export const TAG_COMMIT_CHARS: Record = { + enter: "\n", + space: " ", + comma: "," +}; + export const inputField = tv({ base: "flex items-center rounded-sm border bg-background-default transition-colors", variants: { diff --git a/web/src/routes/[handle]/[repo]/settings/+page.svelte b/web/src/routes/[handle]/[repo]/settings/+page.svelte index 2a517c8b..a2d9b706 100644 --- a/web/src/routes/[handle]/[repo]/settings/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/+page.svelte @@ -3,6 +3,7 @@ import { resolve } from "$app/paths"; import Button from "$lib/components/ui/Button.svelte"; import Input from "$lib/components/ui/Input.svelte"; + import type { InputTag } from "$lib/components/ui/inputField"; import Select from "$lib/components/ui/Select.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte"; import SettingsEmpty from "$lib/components/settings/SettingsEmpty.svelte"; @@ -66,6 +67,8 @@ }; const branches = $derived([data.repo.defaultBranch, "develop", "trunk"]); + + let topics = $state([{ value: "svelte" }, { value: "motion" }]);
@@ -89,8 +92,11 @@