Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
/** Tailwind class applied to selected rows / columns / cells. */
export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]'

/**
* Fill marking every cell matching the active find query. Reuses the app's
* search-highlight token (the knowledge-base search highlight paints with the
* same one) rather than inventing a third match colour, so the two stay
* theme-tuned together. The ACTIVE match is told apart by the selection
* outline drawn over it, not by a different fill.
*/
export const FIND_MATCH_TINT_BG = 'bg-[var(--highlight-match-bg)]'

/** Default column width in pixels. Used as a fallback when a column hasn't
* been measured yet and as the initial width for newly-added columns. */
export const COL_WIDTH = 160
Expand All @@ -23,5 +32,7 @@ export const CELL_HEADER_CHECKBOX =
/** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */
export const CELL_CONTENT =
'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small'
export const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]'
/** Inset shared by every full-cell overlay, so the tints and the selection
* outline can't drift apart on a border-geometry change. */
export const CELL_OVERLAY_INSET = 'pointer-events-none absolute -top-px -right-px -bottom-px'
export const SELECTION_OVERLAY = `${CELL_OVERLAY_INSET} z-[5] border-[2px] border-[var(--selection)]`
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
CELL,
CELL_CHECKBOX,
CELL_CONTENT,
CELL_OVERLAY_INSET,
FIND_MATCH_TINT_BG,
SELECTION_OVERLAY,
SELECTION_TINT_BG,
} from './constants'
Expand Down Expand Up @@ -67,6 +69,13 @@ export interface DataRowProps {
pinnedOffsets?: Map<string, number>
/** Key of the rightmost pinned column, used to render a separator shadow. */
lastPinnedColKey?: string | null
/**
* Column keys in this row matching the active find query, tinted so every hit
* is visible at once rather than only the one being navigated to. Absent when
* the row has no match, which is the common case and keeps this row's memo
* from re-running for a search elsewhere in the table.
*/
findMatchColumns?: ReadonlySet<string>
}

function cellRangeRowChanged(
Expand Down Expand Up @@ -128,7 +137,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
prev.workflowGroups !== next.workflowGroups ||
prev.activeDispatches !== next.activeDispatches ||
prev.pinnedOffsets !== next.pinnedOffsets ||
prev.lastPinnedColKey !== next.lastPinnedColKey
prev.lastPinnedColKey !== next.lastPinnedColKey ||
prev.findMatchColumns !== next.findMatchColumns
) {
return false
}
Expand Down Expand Up @@ -177,6 +187,7 @@ export const DataRow = React.memo(function DataRow({
activeDispatches,
pinnedOffsets,
lastPinnedColKey,
findMatchColumns,
}: DataRowProps) {
const sel = normalizedSelection
/**
Expand Down Expand Up @@ -299,6 +310,7 @@ export const DataRow = React.memo(function DataRow({
const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol
const isEditing = editingColumnName === column.key
const isHighlighted = inRange || isRowChecked
const isFindMatch = findMatchColumns?.has(column.key)

const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked
const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked
Expand All @@ -323,7 +335,7 @@ export const DataRow = React.memo(function DataRow({
data-pinned={isPinnedCell ? '' : undefined}
className={cn(
CELL,
(isHighlighted || isAnchor || isEditing) && 'relative',
(isHighlighted || isAnchor || isEditing || isFindMatch) && 'relative',
isPinnedCell && 'z-[6] bg-[var(--bg)]',
isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]'
)}
Expand All @@ -342,10 +354,26 @@ export const DataRow = React.memo(function DataRow({
}
onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)}
>
{/* No z-index on purpose: with `auto` it paints in DOM order, so it
sits above the cell background but BELOW the cell text, the
selection tint (z-4) and the anchor outline (z-5). The active
match therefore still reads as the selected cell, and the wash
never dims the value it is pointing at. */}
{isFindMatch && (
<div
className={cn(
CELL_OVERLAY_INSET,
colIndex === 0 ? 'left-0' : '-left-px',
isFirstRow && 'top-0',
FIND_MATCH_TINT_BG
)}
/>
)}
{isHighlighted && (isMultiCell || isRowChecked) && (
<div
className={cn(
'-top-px -right-px -bottom-px pointer-events-none absolute z-[4]',
CELL_OVERLAY_INSET,
'z-[4]',
colIndex === 0 ? 'left-0' : '-left-px',
SELECTION_TINT_BG,
isFirstRow && isTopEdge && 'top-0',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* @vitest-environment jsdom
*
* The find bar's contract with the user: results follow typing (no Enter to
* discover), the counter says which state the search is in, and Enter navigates
* rather than submits.
*/
import { act, createRef, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/emcn', () => ({
Button: ({ children, ...props }: { children: ReactNode } & Record<string, unknown>) => (
<button type='button' {...props}>
{children}
</button>
),
ChipInput: ({
endAdornment,
icon: _icon,
...props
}: { endAdornment?: ReactNode } & Record<string, unknown>) => (
<>
<input {...props} />
{endAdornment}
</>
),
}))

vi.mock('@sim/emcn/icons', () => ({
ChevronDown: () => <span data-icon='chevron-down' />,
ChevronUp: () => <span data-icon='chevron-up' />,
Loader: () => <span data-icon='loader' />,
Search: () => <span data-icon='search' />,
X: () => <span data-icon='x' />,
}))

import { TableFind, type TableFindProps } from './table-find'

let container: HTMLDivElement
let root: Root

beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
act(() => {
root = createRoot(container)
})
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

function render(overrides: Partial<TableFindProps> = {}) {
const props: TableFindProps = {
query: '',
onQueryChange: vi.fn(),
onNext: vi.fn(),
onPrev: vi.fn(),
onSubmit: vi.fn(),
onClose: vi.fn(),
isStale: false,
canNavigate: true,
count: 0,
currentIndex: 0,
truncated: false,
isLoading: false,
inputRef: createRef<HTMLInputElement>(),
...overrides,
}
act(() => root.render(<TableFind {...props} />))
return props
}

function input(): HTMLInputElement {
const el = container.querySelector('input')
if (!el) throw new Error('find input not rendered')
return el
}

function counterText(): string | null {
return container.querySelector('[aria-live="polite"]')?.textContent ?? null
}

function buttonByLabel(label: string): HTMLButtonElement {
const el = container.querySelector(`button[aria-label="${label}"]`)
if (!el) throw new Error(`no button labelled ${label}`)
return el as HTMLButtonElement
}

function press(key: string, init: KeyboardEventInit = {}) {
act(() => {
input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init }))
})
}

describe('TableFind counter', () => {
it('shows nothing before the user has typed', () => {
render({ query: '' })
expect(counterText()).toBe('')
})

it('counts matches as 1-based', () => {
render({ query: 'a', count: 12, currentIndex: 0 })
expect(counterText()).toBe('1 of 12')
render({ query: 'a', count: 12, currentIndex: 11 })
expect(counterText()).toBe('12 of 12')
})

it('marks a server-capped result set', () => {
render({ query: 'a', count: 1000, currentIndex: 0, truncated: true })
expect(counterText()).toBe('1 of 1000+')
})

it('says No results only once the search has settled', () => {
render({ query: 'zzz', count: 0, isLoading: true })
expect(counterText()).toBe('')
expect(container.querySelector('[data-icon="loader"]')).not.toBeNull()

render({ query: 'zzz', count: 0, isLoading: false })
expect(counterText()).toBe('No results')
})

// Blanking the tally on each keystroke reads as the search breaking; the
// previous term's count holds until the new one lands.
it('keeps the previous count visible while the next result set loads', () => {
render({ query: 'ab', count: 3, currentIndex: 1, isLoading: true })
expect(counterText()).toBe('2 of 3')
})

it('keeps the counter mounted and width-reserved before the user types', () => {
render({ query: '' })
const region = container.querySelector('[aria-live="polite"]')
expect(region).not.toBeNull()
expect(region?.className).toContain('min-w-[64px]')
})
})

describe('TableFind keyboard', () => {
it('navigates on Enter rather than submitting a search', () => {
const props = render({ query: 'a', count: 3 })
press('Enter')
expect(props.onNext).toHaveBeenCalledTimes(1)
expect(props.onPrev).not.toHaveBeenCalled()
})

it('steps backwards on Shift+Enter', () => {
const props = render({ query: 'a', count: 3 })
press('Enter', { shiftKey: true })
expect(props.onPrev).toHaveBeenCalledTimes(1)
expect(props.onNext).not.toHaveBeenCalled()
})

// Committing makes the typed and submitted terms agree instantly, but the
// matches on screen still belong to the previous term until the request
// lands — stepping there would select a cell the box no longer names.
it('does not step while the committed term is still loading', () => {
const props = render({ query: 'abcd', count: 3, isStale: false, canNavigate: false })
press('Enter')
expect(props.onNext).not.toHaveBeenCalled()
expect(props.onSubmit).not.toHaveBeenCalled()

press('Enter', { shiftKey: true })
expect(props.onPrev).not.toHaveBeenCalled()
})

it('disables the arrows until the results describe the term', () => {
render({ query: 'abcd', count: 3, canNavigate: false })
expect(buttonByLabel('Next match').disabled).toBe(true)
expect(buttonByLabel('Previous match').disabled).toBe(true)
})

it('closes on Escape', () => {
const props = render({ query: 'a', count: 3 })
press('Escape')
expect(props.onClose).toHaveBeenCalledTimes(1)
})

// Mid-debounce the visible matches still belong to the previous term, so
// stepping through them would land on a cell the box no longer describes.
it('commits instead of stepping while the results are stale', () => {
const props = render({ query: 'abcd', count: 3, isStale: true })
press('Enter')
expect(props.onSubmit).toHaveBeenCalledTimes(1)
expect(props.onNext).not.toHaveBeenCalled()

press('Enter', { shiftKey: true })
expect(props.onSubmit).toHaveBeenCalledTimes(2)
expect(props.onPrev).not.toHaveBeenCalled()
})
})

describe('TableFind controls', () => {
it('offers a clear button only once there is text', () => {
render({ query: '' })
expect(container.querySelector('button[aria-label="Clear search"]')).toBeNull()

const props = render({ query: 'abc' })
act(() => buttonByLabel('Clear search').click())
expect(props.onQueryChange).toHaveBeenCalledWith('')
})

it('disables navigation while there is nothing to navigate', () => {
render({ query: 'zzz', count: 0 })
expect(buttonByLabel('Next match').disabled).toBe(true)
expect(buttonByLabel('Previous match').disabled).toBe(true)

render({ query: 'a', count: 2 })
expect(buttonByLabel('Next match').disabled).toBe(false)
expect(buttonByLabel('Previous match').disabled).toBe(false)
})
})
Loading
Loading