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
222 changes: 222 additions & 0 deletions packages/app-core/src/components/SearchCreateForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// @vitest-environment jsdom

import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NoteMeta } from '@shared/ipc'
import { useStore } from '../store'
import type { SearchCreateDraft } from '../lib/search-create'
import { SearchCreateForm } from './SearchCreateForm'

;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

function note(title: string, tags: string[] = []): NoteMeta {
return {
path: `inbox/${title}.md`,
title,
folder: 'inbox',
siblingOrder: 0,
createdAt: 0,
updatedAt: 0,
size: 0,
tags,
wikilinks: [],
hasAttachments: false,
assetEmbeds: [],
excerpt: ''
}
}

const draft: SearchCreateDraft = { name: 'zzqx', folderText: '', tags: [] }

describe('SearchCreateForm: footer hints follow the focused field', () => {
let host: HTMLDivElement
let root: Root
let originalState: ReturnType<typeof useStore.getState>
let originalScrollIntoView: PropertyDescriptor | undefined

beforeEach(() => {
originalState = useStore.getState()
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView')
Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() })

useStore.setState({
// The Folder list is built from the system areas rather than from these
// entries, so an empty `folders` still yields the three picker rows. The
// tags on this note are what give the Tags field something to list.
notes: [note('Alpha', ['alpha', 'beta'])],
folders: [],
systemFolderLabels: {},
vaultSettings: null,
activeNote: null
})
})

afterEach(() => {
act(() => root.unmount())
host.remove()
document.body.innerHTML = ''
if (originalScrollIntoView) {
Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView)
} else {
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
}
vi.restoreAllMocks()
useStore.setState(originalState, true)
})

const render = (): void => {
act(() =>
root.render(
createElement(SearchCreateForm, {
draft,
onBack: vi.fn(),
onCreate: vi.fn(),
onOpenExisting: vi.fn()
})
)
)
}

const field = (id: string): HTMLInputElement => {
const el = document.querySelector<HTMLInputElement>(`#search-create-${id}`)
if (!el) throw new Error(`${id} input not rendered`)
return el
}

/** The footer is the only `div` carrying the hook, so this misses the
* `Shift+↵` chip on the collision button, which is a `kbd`. */
const hints = (): string[] =>
[...document.querySelectorAll('div[data-keyboard-hints] > span')].map(
(span) => span.textContent?.replace(/\s+/g, ' ').trim() ?? ''
)

const focusField = (id: string): void => {
act(() => {
field(id).focus()
})
}

const type = (id: string, value: string): void => {
const el = field(id)
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
setter.call(el, value)
el.dispatchEvent(new Event('input', { bubbles: true }))
})
}

const press = (id: string, init: KeyboardEventInit): void => {
act(() => {
field(id).dispatchEvent(
new KeyboardEvent('keydown', { ...init, bubbles: true, cancelable: true })
)
})
}

it('names Tab and offers create note in Name, with no list keys on arrival', () => {
render()
// The form opens focused in Name, which renders no list at all, so the
// pick hint has nothing to act on and must not be offered.
expect(hints()).toEqual(['↵ create note', 'tab next field', 'esc back'])
})

it('offers pick and create note in Folder, where the system areas always give rows', () => {
render()
focusField('folder')
expect(hints()).toEqual(['↑↓ pick', '↵ create note', 'tab next field', 'esc back'])
})

it('names the folder when a row is highlighted', () => {
render()
focusField('folder')
press('folder', { key: 'ArrowDown' })
expect(hints()).toEqual(['↑↓ pick', '↵ choose folder', 'tab next field', 'esc back'])
})

it('drops pick when the typed folder matches nothing', () => {
render()
focusField('folder')
// The system folders guarantee rows only until the text filters them all
// away; a path that does not exist yet is a legitimate thing to type.
type('folder', 'zzz')
expect(hints()).toEqual(['↵ create note', 'tab next field', 'esc back'])
})

it('offers pick and create note in Tags once the vault has tags to list', () => {
render()
focusField('tags')
expect(hints()).toEqual(['↑↓ pick', '↵ create note', 'tab next field', 'esc back'])
})

it('names the tag when a row is highlighted', () => {
render()
focusField('tags')
press('tags', { key: 'ArrowDown' })
expect(hints()).toEqual(['↑↓ pick', '↵ add tag', 'tab next field', 'esc back'])
})

it('still says add tag once the highlight cycles off the last row', () => {
render()
// Two tags in the vault. The highlight wraps from the last row back to the
// typed value, where Enter still commits what is typed rather than
// creating the note, so the label must not fall back to `create note`.
focusField('tags')
type('tags', 'fresh')
press('tags', { key: 'ArrowDown' })
press('tags', { key: 'ArrowDown' })
press('tags', { key: 'ArrowDown' })
expect(hints()).toEqual(['↑↓ pick', '↵ add tag', 'tab next field', 'esc back'])
})

it('keeps pick when the typed-tag row is all there is, and still says add tag', () => {
// A vault with no tags: typing leaves exactly one row, the `Add #…` one.
// Moving the highlight off it does not remove the row, so pick stays true —
// what changes is that Enter now commits the typed text instead.
useStore.setState({ notes: [note('Alpha')] })
render()
focusField('tags')
type('tags', 'fresh')
press('tags', { key: 'ArrowDown' })
expect(hints()).toEqual(['↑↓ pick', '↵ add tag', 'tab next field', 'esc back'])
})

it('offers no Enter hint at all when the typed tag is not a valid tag', () => {
render()
focusField('tags')
// Enter has nothing to do here: no row is highlighted and `normalizeTag`
// rejects the text, so it neither adds a tag nor creates the note. The
// reason is already on screen in the status line.
type('tags', '!!!')
expect(hints()).toEqual(['tab next field', 'esc back'])
expect(document.querySelector('[data-search-create-status="error"]')).not.toBeNull()
})

it('keeps the hardware-hint hook in every state', () => {
render()
expect(document.querySelector('div[data-keyboard-hints]')).not.toBeNull()
focusField('folder')
expect(document.querySelector('div[data-keyboard-hints]')).not.toBeNull()
type('folder', 'zzz')
expect(document.querySelector('div[data-keyboard-hints]')).not.toBeNull()
focusField('tags')
expect(document.querySelector('div[data-keyboard-hints]')).not.toBeNull()
type('tags', '!!!')
expect(document.querySelector('div[data-keyboard-hints]')).not.toBeNull()
})

it('keeps Back and Create reachable regardless of the hints', () => {
render()
// The hook exists so a phone shell can drop the hints while keeping the
// buttons, so they must survive every state the footer can be in.
for (const focus of [null, 'folder', 'tags'] as const) {
if (focus) focusField(focus)
const labels = [...document.querySelectorAll('button')].map((b) => b.textContent?.trim())
expect(labels).toContain('Back')
expect(labels).toContain('Create')
}
})
})
45 changes: 41 additions & 4 deletions packages/app-core/src/components/SearchCreateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,36 @@ export function SearchCreateForm({
?.scrollIntoView({ block: 'nearest' })
}, [activeRow])

// The footer has to describe the focused field, not the form in general. `↑↓`
// only ever moved through the choice rows, and the Name field has neither
// rows nor a palette-nav handler, so naming it there advertised a capability
// that was not on screen to use. The rows can also be empty even when the
// field is focusable: the system folders guarantee them in Folder, but a
// typed path matching nothing empties the list, and a vault with no tags
// leaves Tags empty until something valid is typed.
const hasList =
focused === 'folder' ? folderRows.length > 0
: focused === 'tags' ? tagRows.length > 0
: false
// Enter means three different things depending on the field and on whether a
// row actually stands behind the highlight, and one word cannot cover them.
// The highlight only counts while a row is behind it: typing in Folder
// preselects row 0 so Enter picks without an arrow key first (#467), and that
// index outlives the row once the text filters the list down to nothing —
// `folderRows[0]` is then `undefined` and Enter falls through to `create()`.
//
// In Tags the third case is a typed tag, which commits as a chip rather than
// creating anything, so it reads `add tag` too. Typed text that is not a
// valid tag leaves Enter with nothing to do at all, and offering it any
// label would be a promise it cannot keep, so the hint is dropped and the
// reason is left to the status line above.
const rowPicked = hasList && activeRow >= 0
const inTags = focused === 'tags'
const enterLabel =
rowPicked ? (inTags ? 'add tag' : 'choose folder')
: inTags && typedTag.trim() ? (normalizeTag(typedTag) ? 'add tag' : null)
: 'create note'

const create = (): void => {
if (!canCreate || !nameCheck.title || !destination.destination) return
// Text still sitting in the Tags field counts: the user typed it and then
Expand Down Expand Up @@ -485,11 +515,18 @@ export function SearchCreateForm({
hides every other palette footer cannot be allowed to match it
(#842). A tablet with a keyboard keeps them. */}
<div data-keyboard-hints="" className="flex flex-wrap items-center gap-x-4 gap-y-1">
{hasList && (
<span>
<kbd className="rounded bg-paper-200 px-1">↑↓</kbd> pick
</span>
)}
{enterLabel && (
<span>
<kbd className="rounded bg-paper-200 px-1">↵</kbd> {enterLabel}
</span>
)}
<span>
<kbd className="rounded bg-paper-200 px-1">↑↓</kbd> pick
</span>
<span>
<kbd className="rounded bg-paper-200 px-1">↵</kbd> create
<kbd className="rounded bg-paper-200 px-1">tab</kbd> next field
</span>
<span>
<kbd className="rounded bg-paper-200 px-1">esc</kbd> back
Expand Down
Loading