Files
windmill/frontend/src/lib/components/text_input/TextInput.svelte
T
GuilhemandClaude Opus 5 1462f17643 feat: rework the evals dataset drawer and run navigation (#10884)
* fix: create eval datasets from the run dialog, not the empty table

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* feat: paginate the evals dialog and show live run progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* feat: rework the evals dataset drawer and run navigation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: take the write lock on the empty-state add-a-case action

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: paginate the cases editor and add keyboard page navigation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: wire cases pagination and select runs from the keyboard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: one highlight for pointer and keyboard, and guard keys on the topmost overlay

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: restore page navigation and answer arrows outside the pages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: scope eval keyboard navigation to the active topmost surface

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: leave Enter to focused controls and declare topmost from drawers too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: highlight with the hover surface and open the highlighted run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: restore the run's dataset on the arrow-right fallback

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: keep the chosen comparison when reopening the same run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* docs: state the Modal Enter caveat on EditableTextarea

Modal handles Enter at `window` in the capture phase and stops propagation,
so inside a dialog the key confirms the dialog rather than committing the
edit. The docstring already carried this caveat for Escape; it now covers
both keys and names `enterConfirms={false}` as the opt-out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: render the case result with the chat's prose stack

GfmMarkdown falls back to the legacy `!prose-xs` when no `prose` is given, so
the case result read differently from a chat answer. Pass `sm`, the stack
AssistantMessage renders with, and drop the wrapper whose `text-xs
text-secondary` competed with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* refactor: drop the narrating comment on the case result render

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: restore focus to the arriving page on keyboard navigation

The restore checked `document.activeElement === document.body` one frame
after the page changed, but the inert-driven reset lands after that frame:
it read the element the user was about to lose and returned. Ask whether
focus was inside the pages before navigating, then focus the arriving page
unconditionally, which removes the race rather than re-timing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: restore focus for any keyboard-driven page change

Arming only from the arrow handler missed Enter, which opens the highlighted
run from the page itself and never reaches this component. Record whether the
last interaction was a key pressed with focus inside the pages — cleared on
pointerdown — so any caller-driven keyboard navigation restores focus too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: track focus position rather than the key that navigated

Arming from a keydown listener cannot work for Escape: Modal handles it at
`window` in the capture phase, registered before this component, and steps a
level back from there — Svelte flushes this component's effects inside that
handler, before our listener runs. Track whether focus sits in the pages as
it moves, so the answer is already settled when the page changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: take focus back when a navigation leaves it nowhere

The trail's back button sits outside the pages and is removed as the level it
returns from closes, so activating it from the keyboard left focus on a dead
element. Claim the arriving page when focus was in the pages, or when it has
ended up on the body — never off a control that outlives the navigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

* fix: type the TextInput binding as the textarea it renders

`TextInput` is generic over its underlying element and defaults to `'input'`,
so binding the textarea instance to a bare `TextInput` failed svelte-check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:30:26 +02:00

193 lines
6.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script module lang="ts">
export function inputBorderClass({
error,
forceFocus
}: {
error?: boolean
forceFocus?: boolean
} = {}) {
return twMerge(
'transition-colors border border-border-light',
forceFocus
? '!border-border-selected'
: '!border-border-light hover:!border-border-selected/50 focus:!border-border-selected',
error
? '!border-red-300 dark:!border-red-400/45 focus:!border-red-400 hover:!border-red-500 dark:hover:!border-red-400/75'
: ''
)
}
export const inputBaseClass =
'rounded-md focus:ring-0 no-default-style text-xs text-primary font-normal !bg-surface-input disabled:!bg-surface-disabled disabled:!border-transparent disabled:!text-disabled disabled:cursor-not-allowed shadow-none !placeholder-hint'
import autosize from '$lib/autosize'
import { ButtonType } from '$lib/components/common/button/model'
export const inputSizeClasses = {
'2xs': twMerge(
ButtonType.UnifiedSizingClasses['2xs'],
ButtonType.UnifiedMinHeightClasses['2xs'],
'px-1 !py-0.5'
),
xs: twMerge(
ButtonType.UnifiedSizingClasses.xs,
ButtonType.UnifiedMinHeightClasses.xs,
'px-1 !py-0.5'
),
sm: twMerge(
ButtonType.UnifiedSizingClasses.sm,
ButtonType.UnifiedMinHeightClasses.sm,
'px-2 !py-0.5'
),
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> = {
'2xs': 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 py-0.5 → 1rem
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' | 'div' = 'input'">
import type { HTMLAttributes, HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
import { twMerge } from 'tailwind-merge'
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
size?: ButtonType.UnifiedSize
unifiedHeight?: boolean
underlyingInputEl?: UnderlyingInputElT
/**
* Passed to the `autosize` action on the `textarea` variant. Chiefly `minHeight: 0`, for a
* field that hugs one line instead of reserving the action's 30px floor.
*/
autosizeParams?: import('$lib/autosize').AutosizeParams
}
export function focus() {
inputEl?.focus()
}
export function 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 | HTMLDivElement | undefined = $state()
let {
inputProps: _inputProps,
value = $bindable(),
class: className = '',
error,
size = 'md',
unifiedHeight = true,
underlyingInputEl: _underlyingInputEl,
autosizeParams
}: Props<UnderlyingInputElT> = $props()
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(
inputBaseClass,
inputSizeClasses[size],
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
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'}
<!-- We hardcode the py value because since textareas can be multiline, we can't rely on
the fact that it auto-centers -->
<textarea
class={twMerge('py-[0.45rem]', fullClassName)}
{...inputProps}
onpointerdown={(e) => e.stopImmediatePropagation()}
bind:this={inputEl}
bind:value
use:autosize={autosizeParams}
></textarea>
{:else if underlyingInputEl === 'input'}
<input
{...inputProps}
class={fullClassName}
onpointerdown={(e) => e.stopImmediatePropagation()}
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}