Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/worker/client/routes/record-table.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderToString } from 'remix/ui/server'
import { expect, test } from 'vitest'
import {
RecordTable,
RecordTableSearch,
type RecordTableColumn,
} from '#client/routes/record-table.tsx'

Expand Down Expand Up @@ -133,6 +134,26 @@ test('record table keeps container drops, row links, and expand/pane selection c
expect(overflowHtml).toContain('overflow: clip')
})

test('record table search stays uncontrolled so the first keystroke cannot remount it', async () => {
const html = await renderToString(
jsx(RecordTableSearch, {
label: 'Search packages',
placeholder: 'Search by name, id, description, or tag',
value: '',
onInput() {},
}),
)

expect(html).toContain('type="search"')
expect(html).toContain('aria-label="Search packages"')
// Passing `value` makes Remix restore the previous query on `input` and
// drop focus. `defaultValue` serializes as the HTML value attribute, so
// the client contract is "no controlled value prop" — pinned here by the
// WebKit cancel-button rules that otherwise appear on the first character.
expect(html).toContain('::-webkit-search-cancel-button')
expect(html).toContain('display: none')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

test('record table empty and busy states keep toolbar layout stable', async () => {
const emptyHtml = await renderToString(
jsx(RecordTable, {
Expand Down
92 changes: 71 additions & 21 deletions packages/worker/client/routes/record-table.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { css, type Handle } from 'remix/ui'
import { css, ref, type Handle } from 'remix/ui'
import { shouldRouterHandleClick } from '#client/client-router.tsx'
import { on } from '#client/event-mixin.ts'
import {
Expand Down Expand Up @@ -315,6 +315,34 @@ const footerCss = {
* they cost 48px, so the label becomes the control's accessible name.
*/

/**
* Live filter fields write the query into the URL on every keystroke. A
* Remix-controlled `value` lets the first character schedule a restore of the
* previous (empty) query, and WebKit's search cancel control appears on that
* same keystroke — either one drops focus and the reader has to click back in.
* The field stays uncontrolled; URL/back-button updates land only while it is
* not focused.
*/
const searchCancelHiddenCss = {
'&::-webkit-search-decoration': {
WebkitAppearance: 'none',
appearance: 'none',
},
'&::-webkit-search-cancel-button': {
WebkitAppearance: 'none',
appearance: 'none',
display: 'none',
},
'&::-webkit-search-results-button': {
WebkitAppearance: 'none',
appearance: 'none',
},
'&::-webkit-search-results-decoration': {
WebkitAppearance: 'none',
appearance: 'none',
},
} as const

export function RecordTableSearch(
handle: Handle<{
label: string
Expand All @@ -323,26 +351,48 @@ export function RecordTableSearch(
onInput: (value: string) => void
}>,
) {
return () => (
<input
type="search"
data-field-ring
value={handle.props.value}
placeholder={handle.props.placeholder}
aria-label={handle.props.label}
mix={[
on('input', (event) =>
handle.props.onInput((event.currentTarget as HTMLInputElement).value),
),
css({
...getAuthInputCss(),
flex: '1 1 12rem',
minWidth: '7rem',
width: 'auto',
}),
]}
/>
)
let input: HTMLInputElement | null = null
const initialValue = handle.props.value
let lastExternalValue = initialValue

return () => {
const nextValue = handle.props.value
if (nextValue !== lastExternalValue) {
lastExternalValue = nextValue
if (input && document.activeElement !== input) {
input.value = nextValue
}
}
return (
<input
type="search"
data-field-ring
defaultValue={initialValue}
placeholder={handle.props.placeholder}
aria-label={handle.props.label}
mix={[
ref((node, signal) => {
input = node as HTMLInputElement
signal.addEventListener('abort', () => {
if (input === node) input = null
})
}),
on('input', (event) =>
handle.props.onInput(
(event.currentTarget as HTMLInputElement).value,
),
),
css({
...getAuthInputCss(),
flex: '1 1 12rem',
minWidth: '7rem',
width: 'auto',
...searchCancelHiddenCss,
}),
]}
/>
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

export function RecordTableSelect(
Expand Down
Loading