feat(frontend): new path component (#9017)

* stash

* ui nits

* Fix contenteditable feedback look (duplicate typing)

* fix right icon wrong position with placeholder

* user editor in Path editor takes correct width

* nits

* nit

* chore: remove assets-operator changes (moved to separate PR)

These files were mistakenly included in this PR and belong in a dedicated PR
("Allow assets page to operators").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: remove sidebar assets-operator change (moved to separate PR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix disabled

* border nit

* Fix disabled styling

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* nit

* Update frontend/src/lib/components/text_input/TextInput.svelte

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Fix disabled tabindex and aria-disabled on contenteditable Select

The useContentEditable branch had an unconditional tabindex="0", keeping
a disabled Select in the tab order, and was missing aria-disabled.
Mirror the TextInput div branch.

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>

* fix: drop obsolete hideFullPath prop from EditorHeader Path usage

* invalidate autocomplete paths on deploy

* nit pixel

* use Badge in auto complete

* nit prevent default

* fix(autocomplete): don't let stale fetch clobber forced refresh

A non-forced fetchWorkspacePaths() that started before invalidateWorkspacePaths()
could still resolve afterward, overwrite the cache, and clear forceNextFetch —
making the post-deploy refresh a no-op. Only write back from the promise that
is still the current pending one, and only clear the force flag when the
completing fetch was itself forced.

* refactor(path): drop unreachable 'group' branch in owner-kind setter

The Select only offers user/folder, so the 'group' branch was dead. Leave a
short note pointing at validateName which still accepts 'group' for
forward-compat.

* fix(path): respect disableEditing on owner-kind selector

Other path-editor controls disable on (disabled || disableEditing); the
owner-kind Select only checked `disabled`, so read-only users (trigger
editors with !can_write) could still toggle User/Folder and mutate the
bound path. Reuse the existing nameDisabled flag.

* Revert "fix(autocomplete): don't let stale fetch clobber forced refresh"

This reverts commit 6649975714.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
This commit is contained in:
Diego Imbert
2026-05-20 15:26:34 +02:00
committed by GitHub
parent 9111f8908d
commit 9c28bbfd69
16 changed files with 306 additions and 158 deletions
+7
View File
@@ -9836,6 +9836,13 @@ paths:
- path_autocomplete
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: force
description: |
bypass the server-side cache and re-query the DB, refreshing the
cache. Used right after a deploy so the new path appears immediately.
in: query
schema:
type: boolean
responses:
"200":
description: deduplicated path list, sorted lexicographically
+18 -6
View File
@@ -12,11 +12,11 @@ use std::{
};
use axum::{
extract::{Extension, Path},
extract::{Extension, Path, Query},
routing::get,
Json, Router,
};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use windmill_common::error::JsonResult;
use crate::db::{ApiAuthed, DB};
@@ -43,16 +43,28 @@ struct ListPathsResponse {
paths: Arc<Vec<String>>,
}
#[derive(Deserialize)]
struct ListPathsQuery {
/// When true, bypass the cached entry and re-query the DB, refreshing the
/// cache. Used by clients that just mutated the workspace (e.g. a deploy)
/// and need the new path reflected immediately.
#[serde(default)]
force: bool,
}
async fn list_paths(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(ListPathsQuery { force }): Query<ListPathsQuery>,
) -> JsonResult<ListPathsResponse> {
if let Some((cached, cached_at)) = PATHS_CACHE.get(&w_id) {
if cached_at.elapsed() < CACHE_TTL {
return Ok(Json(ListPathsResponse { paths: cached }));
if !force {
if let Some((cached, cached_at)) = PATHS_CACHE.get(&w_id) {
if cached_at.elapsed() < CACHE_TTL {
return Ok(Json(ListPathsResponse { paths: cached }));
}
PATHS_CACHE.remove(&w_id);
}
PATHS_CACHE.remove(&w_id);
}
let mut paths: Vec<String> = sqlx::query_scalar!(
@@ -204,7 +204,6 @@
initialPath={snapshotPath ?? path ?? ''}
namePlaceholder={kind}
{kind}
hideFullPath
size="sm"
drawerOffset={4000}
/>
@@ -77,6 +77,7 @@
import { type TriggerContext, type ScheduleTrigger } from './triggers'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import {
deployTriggers,
@@ -566,6 +567,10 @@
})
}
// New/updated path now exists server-side — drop the autocomplete
// cache so it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths($workspaceStore!)
const { draft_triggers: _, ...newSavedFlow } = flowStore.val as OpenFlow & {
draft_triggers: Trigger[]
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { FolderService } from '$lib/gen'
import { workspaceStore, userStore } from '$lib/stores'
import { Pen, PlusIcon } from 'lucide-svelte'
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
import { Button, Drawer, DrawerContent } from './common'
import FolderEditor from './FolderEditor.svelte'
import Select from './select/Select.svelte'
@@ -32,6 +32,7 @@
disableEditing?: boolean
size?: 'sm' | 'md'
drawerOffset?: number
selectInputClass?: string
}
let {
@@ -40,11 +41,10 @@
disabled = $bindable(undefined),
disableEditing = $bindable(undefined),
size = 'md',
drawerOffset = 0
drawerOffset = 0,
selectInputClass
}: Props = $props()
let hovering = $state(false)
async function loadFolders(): Promise<void> {
loadingFolders = true
try {
@@ -198,13 +198,12 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="flex flex-row w-full items-center relative"
class="flex group flex-row w-full items-center relative"
role="group"
onkeydown={handleSelectKeydown}
onmouseenter={() => (hovering = true)}
onmouseleave={() => (hovering = false)}
>
<Select
useContentEditable
bind:value={folderName}
bind:filterText
bind:open={selectOpen}
@@ -214,6 +213,8 @@
{size}
placeholder="Select folder"
class="grow min-w-0"
inputClass={selectInputClass}
RightIcon={ChevronDown}
>
{#snippet endSnippet({ item, close })}
<Button
@@ -246,20 +247,4 @@
</button>
{/snippet}
</Select>
{#if folderName && hovering && !loadingFolders && !disabled && !disableEditing}
<div class="absolute right-2 z-20">
<Button
variant="subtle"
unifiedSize="xs"
wrapperClasses="pl-1"
btnClasses="hover:bg-surface-tertiary"
onClick={() => {
editingFolder = folderName
viewFolder?.openDrawer()
}}
startIcon={{ icon: Pen }}
iconOnly
/>
</div>
{/if}
</div>
+51 -76
View File
@@ -29,16 +29,19 @@
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import { Alert, Button } from './common'
import Badge from './common/badge/Badge.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import { random_adj } from './random_positive_adjetive'
import { Folder, SearchCode, User } from 'lucide-svelte'
import { ChevronDown, SearchCode } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { tick } from 'svelte'
import FolderPicker from './FolderPicker.svelte'
import PathNameAutocomplete from './PathNameAutocomplete.svelte'
import TextInput from './text_input/TextInput.svelte'
import TextInput, {
inputBaseClass,
inputBorderClass,
inputSizeClasses
} from './text_input/TextInput.svelte'
import Select from './select/Select.svelte'
import { twMerge } from 'tailwind-merge'
import InputError from './InputError.svelte'
type PathKind =
@@ -73,7 +76,6 @@
kind: PathKind
hideUser?: boolean
disableEditing?: boolean
hideFullPath?: boolean
size?: 'sm' | 'md'
drawerOffset?: number
}
@@ -91,7 +93,6 @@
kind,
hideUser = false,
disableEditing = false,
hideFullPath = false,
size = 'md',
drawerOffset = 0
}: Props = $props()
@@ -422,48 +423,46 @@
</script>
<div>
<div class="flex gap-2 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center">
<div
class={twMerge(
inputBaseClass,
inputBorderClass({ error: !!error }),
inputSizeClasses[size],
'flex gap-0 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center',
disabled && '!bg-surface-disabled cursor-not-allowed border-none'
)}
>
{#if meta != undefined}
{@const nameDisabled = disabled || disableEditing}
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if !hideUser}
<div class="block">
<ToggleButtonGroup
bind:selected={meta.ownerKind}
on:selected={(e) => {
setDirty()
const kind = e.detail
if (meta) {
if (kind === 'folder') {
<Select
items={[
{ value: 'user', label: 'User' },
{ value: 'folder', label: 'Folder' }
]}
RightIcon={ChevronDown}
transformInputSelectedText={(t) => t.substring(0, 1).toLowerCase()}
inputClass={twMerge('border-none', disabled && '!bg-transparent')}
useContentEditable
bind:value={
() => meta?.ownerKind,
(v) => {
if (!meta || !v) return
setDirty()
meta.ownerKind = v
if (v === 'folder') {
meta.owner = folders?.[0]?.name ?? ''
} else if (kind === 'group') {
meta.owner = 'all'
} else {
// 'group' is unreachable here (Select only offers user/folder)
// but validateName still accepts it for forward-compat.
meta.owner = $userStore?.username?.split('@')[0] ?? ''
}
}
}}
disabled={disabled || disableEditing}
>
{#snippet children({ item })}
<ToggleButton
icon={User}
disabled={disabled || disableEditing}
value="user"
label="User"
{size}
{item}
/>
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
<ToggleButton
icon={Folder}
disabled={disabled || disableEditing}
value="folder"
label="Folder"
{size}
{item}
/>
{/snippet}
</ToggleButtonGroup>
}
disabled={nameDisabled}
/>
</div>
{/if}
{#if !hideUser}
@@ -471,17 +470,18 @@
{/if}
<div>
{#if meta.ownerKind === 'user'}
{@const userOwnerDisabled =
disabled || !($superadmin || ($userStore?.is_admin ?? false)) || disableEditing}
<label class="block shrink min-w-0">
<TextInput
class="!w-36"
class={twMerge('!border-none', userOwnerDisabled && '!bg-transparent')}
{size}
underlyingInputEl="div"
bind:value={meta.owner}
inputProps={{
type: 'text',
placeholder: $userStore?.username ?? '',
onkeydown: setDirty,
disabled:
disabled || !($superadmin || ($userStore?.is_admin ?? false)) || disableEditing
disabled: userOwnerDisabled
}}
/>
</label>
@@ -494,12 +494,13 @@
{disableEditing}
{size}
{drawerOffset}
selectInputClass={twMerge('!border-none', disabled && '!bg-transparent')}
/>
</label>
{/if}
</div>
<div class="text-sm text-secondary">/</div>
<label class="block grow min-w-32 max-w-md">
<label class="block grow min-w-32">
<!-- svelte-ignore a11y_autofocus -->
<PathNameAutocomplete
bind:this={inputP}
@@ -510,37 +511,17 @@
{autofocus}
id="path"
placeholder={namePlaceholder}
disabled={disabled || disableEditing}
disabled={nameDisabled}
onkeyup={handleKeyUp}
textInputClass={twMerge(
'border-none',
nameDisabled && '!bg-transparent disabled:!bg-transparent'
)}
/>
</label>
{/if}
</div>
{#if !hideFullPath}
<div class="flex flex-col w-full mt-2">
<div class="flex justify-start w-full">
<Badge
color="gray"
class="center-center !bg-surface-secondary !text-primary !w-[70px] !h-[24px] rounded-r-none border"
>
Full path
</Badge>
<input
type="text"
readonly
value={path}
size={path?.length || 50}
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
onfocus={({ currentTarget }) => {
currentTarget.select()
}}
/>
<!-- <span class="font-mono text-sm break-all">{path}</span> -->
</div>
</div>
{/if}
<InputError {error} />
{#if pathUsageInFlowsPromise || pathUsageInAppsPromise || pathUsageInScriptsPromise}
@@ -618,9 +599,3 @@
</Alert>
{/if}
</div>
<style>
input:disabled {
background: rgba(200, 200, 200, 0.267);
}
</style>
@@ -19,18 +19,31 @@
* the same page reuse a single fetch per workspace. */
const pathListCache = new Map<string, Entry>()
/** Workspaces whose next fetch must bypass the server-side cache. Set by
* invalidateWorkspacePaths (e.g. after a deploy) and cleared once a forced
* fetch succeeds, so a just-created path shows up immediately instead of
* after the backend's 60s TTL. */
const forceNextFetch = new Set<string>()
export async function fetchWorkspacePaths(workspace: string): Promise<string[]> {
const force = forceNextFetch.has(workspace)
const now = Date.now()
const existing = pathListCache.get(workspace)
if (existing) {
// When forcing, ignore any cached/in-flight entry — it may predate the
// deploy (or have been written by a fetch that hit the stale backend
// cache) and would otherwise mask the new path.
if (existing && !force) {
if (existing.paths && now - existing.at < PATH_LIST_TTL_MS) return existing.paths
if (existing.pending) return existing.pending
}
const pending = (async () => {
try {
const res = await PathAutocompleteService.listPathAutocompletePaths({ workspace })
const res = await PathAutocompleteService.listPathAutocompletePaths({ workspace, force })
const paths = res.paths ?? []
pathListCache.set(workspace, { at: Date.now(), paths, pending: null })
// Only clear the force flag once a forced fetch has actually
// landed fresh data, so a failed retry still forces.
forceNextFetch.delete(workspace)
return paths
} catch (_e) {
pathListCache.delete(workspace)
@@ -43,6 +56,7 @@
export function invalidateWorkspacePaths(workspace: string) {
pathListCache.delete(workspace)
forceNextFetch.add(workspace)
}
/** Derive the set of path segments that exist directly under a given folder
@@ -72,6 +86,7 @@
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { workspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
@@ -84,6 +99,7 @@
id?: string
size?: InputSize
error?: string | boolean
textInputClass?: string
onkeyup?: (e: KeyboardEvent) => void
}
@@ -96,6 +112,7 @@
id,
size = 'md',
error,
textInputClass,
onkeyup
}: Props = $props()
@@ -297,6 +314,7 @@
bind:value
{size}
{error}
class={textInputClass}
inputProps={{
disabled,
type: 'text',
@@ -344,18 +362,17 @@
{cycleMode ? 'Tab to cycle' : 'Tab'}
</span>
{#each displayedOptions as opt, i (opt.name)}
<button
<Badge
clickable
selected={i === displayedActiveIndex}
class="font-mono transition-colors"
type="button"
tabindex="-1"
onmousedown={(e) => e.preventDefault()}
tabindex={-1}
onmousedown={(e: MouseEvent) => e.preventDefault()}
onclick={() => selectOption(opt)}
class="px-1.5 py-0 rounded border text-[11px] font-mono leading-5 transition-colors
{i === displayedActiveIndex
? 'border-border-selected bg-surface-selected text-primary'
: 'border-border-light bg-surface-secondary text-secondary hover:border-border-selected'}"
>
{opt.name}/
</button>
</Badge>
{/each}
</div>
{/if}
@@ -7,6 +7,7 @@
import { sendUserToast } from '$lib/toast'
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
import ResourceForm from './ResourceForm.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import Alert from './common/alert/Alert.svelte'
import { resource } from 'runed'
import { deepEqual } from 'fast-equals'
@@ -246,6 +247,9 @@
}
})
}
// Path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
}
sendUserToast(
dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource`
@@ -43,6 +43,7 @@
type Value
} from '$lib/utils'
import Path from './Path.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import ScriptEditor from './ScriptEditor.svelte'
import { Alert, Button, Drawer, SecondsInput, Tab, TabContent, Tabs } from './common'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
@@ -626,6 +627,10 @@
}
})
// New/updated path now exists server-side — drop the autocomplete
// cache so it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths($workspaceStore!)
if (!initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
@@ -150,7 +150,6 @@
initialPath={path ?? ''}
namePlaceholder={kind}
{kind}
hideFullPath
size="sm"
drawerOffset={4000}
/>
@@ -199,7 +198,6 @@
initialPath={path ?? ''}
namePlaceholder={kind}
{kind}
hideFullPath
size="sm"
drawerOffset={4000}
/>
@@ -10,6 +10,7 @@
import { canWrite } from '$lib/utils'
import { Save } from 'lucide-svelte'
import VariableForm from './VariableForm.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import WsSpecificVersions from './WsSpecificVersions.svelte'
import { resource } from 'runed'
import { deepEqual } from 'fast-equals'
@@ -186,6 +187,9 @@
}
})
}
// Path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
}
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
dispatch('create')
@@ -64,6 +64,7 @@
import EditorHeader from '$lib/components/EditorHeader.svelte'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { goto } from '$app/navigation'
import HideButton from './settingsPanel/HideButton.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
@@ -215,6 +216,9 @@
preserve_on_behalf_of: preserveOnBehalfOf || undefined
}
})
// New path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: $summary,
value: structuredClone($state.snapshot($app)),
@@ -313,6 +317,7 @@
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: $summary,
value: structuredClone($state.snapshot($app)),
@@ -1,4 +1,5 @@
import { AppService, FlowService, ScriptService } from '$lib/gen'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
type ItemKind = 'flow' | 'script' | 'app'
@@ -74,4 +75,8 @@ export async function updateItemPathAndSummary(opts: {
}
})
}
// The path changed (rename/move) — drop the autocomplete cache so the new
// path shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(workspace)
}
@@ -3,6 +3,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import { isMac, userPathPrefix } from '$lib/utils'
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { rawAppToHubUrl } from '$lib/hub'
@@ -253,6 +254,9 @@
css
}
})
// New path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: summary,
value: structuredClone(stateSnapshot(app)),
@@ -362,6 +366,7 @@
}
})
invalidatePicker($workspaceStore!, 'app')
invalidateWorkspacePaths($workspaceStore!)
savedApp = {
summary: summary,
value: structuredClone(stateSnapshot(app)),
@@ -13,6 +13,7 @@
import {
inputBaseClass,
inputBorderClass,
inputLeadingClasses,
inputSizeClasses
} from '../text_input/TextInput.svelte'
import { ButtonType } from '../common/button/model'
@@ -53,6 +54,7 @@
onBlur,
onClear,
onCreateItem,
useContentEditable = false,
startSnippet,
endSnippet,
bottomSnippet
@@ -89,6 +91,7 @@
onBlur?: () => void
onClear?: () => void
onCreateItem?: (value: string) => void
useContentEditable?: boolean
startSnippet?: Snippet<[{ item: ProcessedItem<Value>; close: () => void }]>
endSnippet?: Snippet<[{ item: ProcessedItem<Value>; close: () => void }]>
bottomSnippet?: Snippet<[{ close: () => void }]>
@@ -97,7 +100,7 @@
let disabled = $derived(_disabled || (loading && !value))
let iconSize = $derived(ButtonType.UnifiedIconSizes[size])
let inputEl: HTMLInputElement | undefined = $state()
let inputEl: HTMLInputElement | HTMLDivElement | undefined = $state()
let processedItems: ProcessedItem<Value>[] = $derived.by(() => {
let args = { items, createText, filterText, groupBy, onCreateItem, sortBy }
@@ -135,6 +138,16 @@
let text = valueEntry?.label ?? getLabel({ value }) ?? ''
return transformInputSelectedText?.(text, value) ?? text
})
// contenteditable is owned by the browser while typing, so the text node
// must be set imperatively — `>{expr}</div>` would race with the browser's
// own DOM mutations and produce duplicated text.
$effect(() => {
const target = open ? filterText : inputText
if (useContentEditable && inputEl && inputEl.textContent !== target) {
inputEl.textContent = target
}
})
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -171,39 +184,79 @@
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
{autofocus}
{disabled}
type="text"
bind:value={() => (open ? filterText : inputText), (v) => open && (filterText = v)}
placeholder={loading && !value
? 'Loading...'
: value && !showPlaceholderOnOpen
? inputText
: placeholder}
style={containerStyle}
class={twMerge(
inputBaseClass,
inputSizeClasses[size],
ButtonType.UnifiedHeightClasses[size],
inputBorderClass({ error, forceFocus: open }),
'w-full',
open ? '' : 'cursor-pointer',
// Show value as placeholder when opening the dropdown and the search is empty
!value ? 'placeholder-hint' : '!placeholder-primary',
(clearable || RightIcon) && !disabled && value ? 'pr-8' : '',
inputClass ?? ''
)}
autocomplete="off"
oninput={(e) => {
// Explicitly open dropdown if closed and update filterText
if (!open) open = true
filterText = e.currentTarget.value
}}
onpointerdown={() => (open = true)}
bind:this={inputEl}
{id}
/>
{#if useContentEditable}
{@const placeholderText =
loading && !value ? 'Loading...' : value && !showPlaceholderOnOpen ? inputText : placeholder}
<div
contenteditable={!disabled}
role="textbox"
aria-disabled={disabled}
tabindex={disabled ? -1 : 0}
{id}
style={containerStyle}
class={twMerge(
inputBaseClass,
inputSizeClasses[size],
ButtonType.UnifiedHeightClasses[size],
inputBorderClass({ error, forceFocus: open }),
'w-full whitespace-pre overflow-hidden',
inputLeadingClasses[size],
'focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',
open ? '' : 'cursor-pointer',
loading || (clearable && !disabled && value) || RightIcon ? 'pr-7' : '',
'empty:before:content-[attr(data-placeholder)]',
!value ? 'empty:before:text-hint' : 'empty:before:text-primary',
disabled && '!bg-surface-disabled !border-transparent !text-disabled pointer-events-none',
inputClass ?? ''
)}
data-placeholder={placeholderText}
oninput={(e) => {
if (!open) open = true
filterText = e.currentTarget.textContent ?? ''
}}
onpointerdown={() => !disabled && (open = true)}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
bind:this={inputEl}
></div>
{:else}
<input
{autofocus}
{disabled}
type="text"
bind:value={() => (open ? filterText : inputText), (v) => open && (filterText = v)}
placeholder={loading && !value
? 'Loading...'
: value && !showPlaceholderOnOpen
? inputText
: placeholder}
style={containerStyle}
class={twMerge(
inputBaseClass,
inputSizeClasses[size],
ButtonType.UnifiedHeightClasses[size],
inputBorderClass({ error, forceFocus: open }),
'w-full',
open ? '' : 'cursor-pointer',
// Show value as placeholder when opening the dropdown and the search is empty
!value ? 'placeholder-hint' : '!placeholder-primary',
loading || (clearable && !disabled && value) || RightIcon ? 'pr-8' : '',
inputClass ?? ''
)}
autocomplete="off"
oninput={(e) => {
// Explicitly open dropdown if closed and update filterText
if (!open) open = true
filterText = e.currentTarget.value
}}
onpointerdown={() => !disabled && (open = true)}
bind:this={inputEl}
{id}
/>
{/if}
<SelectDropdown
class={dropdownClass}
{disablePortal}
@@ -37,14 +37,38 @@
md: twMerge(ButtonType.UnifiedSizingClasses.md, ButtonType.UnifiedMinHeightClasses.md, 'px-2'),
lg: twMerge(ButtonType.UnifiedSizingClasses.lg, ButtonType.UnifiedMinHeightClasses.lg, 'px-2')
}
// Base leading == (unified height vertical padding) so a single-line
// contenteditable div centers its text the way a native <input> does.
//
// In "large mode" (viewport ≥ 1760px, where app.css bumps :root to 18px →
// font 13.5px) headless-Chromium ink measurement showed the text sitting
// ~1px low with the base leading. The residual is a fixed ~2px of line box,
// so the exact centered value there is (content-box height 2px). Scoped to
// the same 1760px breakpoint as the font-size bump; small mode is unchanged.
export const inputLeadingClasses: Record<ButtonType.UnifiedSize, string> = {
xs: 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 py-0.5 → 1rem
sm: 'leading-6 min-[1760px]:leading-[calc(1.5rem_-_2px)]', // h-7 py-0.5 → 1.5rem
md: 'leading-8 min-[1760px]:leading-[calc(2rem_-_2px)]', // h-8, no py → 2rem
lg: 'leading-10 min-[1760px]:leading-[calc(2.5rem_-_2px)]' // h-10, no py → 2.5rem
}
</script>
<script lang="ts" generics="UnderlyingInputElT extends 'input' | 'textarea' = 'input'">
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
<script lang="ts" generics="UnderlyingInputElT extends 'input' | 'textarea' | 'div' = 'input'">
import type { HTMLAttributes, HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
import { twMerge } from 'tailwind-merge'
type Props<UnderlyingInputElT extends 'input' | 'textarea'> = {
inputProps?: UnderlyingInputElT extends 'input' ? HTMLInputAttributes : HTMLTextareaAttributes
type DivInputProps = HTMLAttributes<HTMLDivElement> & {
disabled?: boolean
placeholder?: string
}
type Props<UnderlyingInputElT extends 'input' | 'textarea' | 'div'> = {
inputProps?: UnderlyingInputElT extends 'input'
? HTMLInputAttributes
: UnderlyingInputElT extends 'textarea'
? HTMLTextareaAttributes
: DivInputProps
value?: string | number
class?: string
error?: string | boolean
@@ -58,10 +82,18 @@
}
export function select() {
inputEl?.select()
if (inputEl instanceof HTMLDivElement) {
const range = document.createRange()
range.selectNodeContents(inputEl)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
} else {
inputEl?.select()
}
}
let inputEl: HTMLInputElement | HTMLTextAreaElement | undefined = $state()
let inputEl: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement | undefined = $state()
let {
inputProps: _inputProps,
@@ -75,6 +107,8 @@
let underlyingInputEl = $derived(_underlyingInputEl ?? ('input' as const))
let inputProps = $derived(_inputProps as any)
let isDiv = $derived(underlyingInputEl === 'div')
let divDisabled = $derived(isDiv && Boolean(inputProps?.disabled))
let fullClassName = $derived(
twMerge(
@@ -84,9 +118,23 @@
inputBorderClass({ error: !!error }),
unifiedHeight ? ButtonType.UnifiedHeightClasses[size] : '',
'w-full',
isDiv &&
`whitespace-pre overflow-hidden ${inputLeadingClasses[size]} focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 empty:before:content-[attr(data-placeholder)] empty:before:text-hint`,
divDisabled && '!bg-surface-disabled !border-transparent !text-disabled cursor-not-allowed',
className
)
)
// contenteditable is mutated by the browser as the user types — a templated
// `>{value}</div>` would race with that and produce duplicated text. Sync
// imperatively, with an equality guard so user input doesn't echo back.
$effect(() => {
if (underlyingInputEl !== 'div' || !inputEl) return
const target = value == null ? '' : String(value)
if (inputEl.textContent !== target) {
inputEl.textContent = target
}
})
</script>
{#if underlyingInputEl === 'textarea'}
@@ -108,4 +156,25 @@
bind:this={inputEl}
bind:value
/>
{:else if underlyingInputEl === 'div'}
{@const { disabled, placeholder, ...divProps } = (inputProps ?? {}) as DivInputProps}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="textbox"
aria-disabled={disabled}
tabindex={disabled ? -1 : 0}
contenteditable={!disabled}
{...divProps}
onkeydown={(e) => {
if (e.key === 'Enter') e.preventDefault()
divProps?.onkeydown?.(e)
}}
class={fullClassName}
data-placeholder={placeholder ?? ''}
onpointerdown={(e) => e.stopImmediatePropagation()}
oninput={(e) => {
value = e.currentTarget.textContent ?? ''
}}
bind:this={inputEl}
></div>
{/if}