fix(host): review round 3 — card-scope escape/focus-leave cancel, pick refocus; record display-policy trade-offs
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
|
||||
README.md: 95d2d66406210f4ba687ef5a45a38b142abd6f26
|
||||
README.zh.md: 9da374bec80f80085ff36871c227c496bb1fde7b
|
||||
README.md: 7813e0e9c589d1f37471b08ebafcf08878cbf768
|
||||
README.zh.md: 46538ed6f1cf23357cc4f8e289a8117d0e5e017d
|
||||
|
||||
@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
|
||||
|
||||
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
flex: 1 1 0;
|
||||
min-width: 256px;
|
||||
overflow-y: auto;
|
||||
/* The overlay scrollbar paints at the column's edge; keep the row pills
|
||||
/* The themed scrollbar occupies the column's edge (styled scrollbars are
|
||||
* classic, gutter-taking ones); the extra clearance keeps the row pills
|
||||
* clear of the thumb. */
|
||||
padding-right: 8px;
|
||||
}
|
||||
@@ -283,6 +284,13 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
|
||||
* observers; display:contents keeps header/content/footer as direct flex
|
||||
* children of the Modal card. */
|
||||
.editorScope {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.footerGap {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
|
||||
* legal POSIX name character. Still a heuristic at the last step: a POSIX
|
||||
* home directory whose own name contains a backslash would misread.
|
||||
* TODO: replace with a host-stamped `separator` field on the wire
|
||||
* DirectoryListing so the platform fact travels verbatim.
|
||||
* DirectoryListing so the platform fact travels verbatim (the trade-off is
|
||||
* recorded in the directory-picker capability seam Agent Note).
|
||||
*/
|
||||
function separatorOf(listing: DirectoryListing): '\\' | '/' {
|
||||
return listing.home.includes('\\') ? '\\' : '/'
|
||||
@@ -131,7 +132,13 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr
|
||||
// where the blur lands before our guards) drop this click.
|
||||
// Outside editing, rows keep native focus behavior.
|
||||
onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined}
|
||||
onClick={() => { onPick(entry) }}
|
||||
onClick={(event) => {
|
||||
// A pick during editing is about to unmount the focused
|
||||
// input; park focus on the picked row so keyboard traversal
|
||||
// stays inside the dialog (the Modal has no focus trap).
|
||||
if (pathEditing) event.currentTarget.focus()
|
||||
onPick(entry)
|
||||
}}
|
||||
>
|
||||
{selected
|
||||
? <IconFolderOpen16 size={16} className={css.rowIconSelected} />
|
||||
@@ -386,177 +393,197 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
className={clsx(css.dialog)}
|
||||
headless
|
||||
>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{t('browser.title')}</h2>
|
||||
<div className={css.crumbBar}>
|
||||
{pathDraft === null
|
||||
? (
|
||||
<>
|
||||
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className={css.crumbSeat}>
|
||||
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumb}
|
||||
disabled={parentInert}
|
||||
onClick={() => { navigate(crumb.path) }}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={t('browser.editPath')}
|
||||
// Stays available with no listed level: when the home
|
||||
// listing itself fails, typing an absolute path is the one
|
||||
// remaining way forward.
|
||||
disabled={parentInert}
|
||||
onClick={() => {
|
||||
{/* Path-edit cancellation is observed at the card scope, not the
|
||||
* input: once Tab parks focus on a filtered row the input is off the
|
||||
* event path, yet Escape must still collapse the editor (not the
|
||||
* dialog) and a further focus move out of the card must still
|
||||
* cancel. display:contents keeps header/content/footer as direct
|
||||
* flex children of the Modal card. */}
|
||||
<div
|
||||
className={css.editorScope}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape' || pathDraft === null) return
|
||||
// stopPropagation keeps the card-scope Escape from the Modal's
|
||||
// document listener — the same containment the input previously
|
||||
// provided for itself.
|
||||
event.stopPropagation()
|
||||
cancelPathEdit()
|
||||
}}
|
||||
// Focus leaving THIS dialog card while editing cancels like Escape.
|
||||
// Guarded non-cancel paths: window/tab focus loss (document no
|
||||
// longer focused); a focus move that stays inside the card (Tab
|
||||
// onto the filtered rows or the footer toggle); and pointer paths,
|
||||
// where rows and the toggle suppress focus steal on mousedown while
|
||||
// editing so their click lands first. Enter keeps focus in the
|
||||
// input while its navigation is in flight, so a submitted path is
|
||||
// never withdrawn here. Anchored to this card via closest, not any
|
||||
// [role="dialog"], so focus escaping into a sibling overlay cancels.
|
||||
onBlur={(event) => {
|
||||
if (pathDraft === null) return
|
||||
if (!document.hasFocus()) return
|
||||
const card = event.currentTarget.closest('[role="dialog"]')
|
||||
/* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */
|
||||
if (card === null) return
|
||||
if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return
|
||||
cancelPathEdit()
|
||||
}}
|
||||
>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{t('browser.title')}</h2>
|
||||
<div className={css.crumbBar}>
|
||||
{pathDraft === null
|
||||
? (
|
||||
<>
|
||||
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className={css.crumbSeat}>
|
||||
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumb}
|
||||
disabled={parentInert}
|
||||
onClick={() => { navigate(crumb.path) }}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={t('browser.editPath')}
|
||||
// Stays available with no listed level: when the home
|
||||
// listing itself fails, typing an absolute path is the one
|
||||
// remaining way forward.
|
||||
disabled={parentInert}
|
||||
onClick={() => {
|
||||
// Opening the editor supersedes any pending listing: a
|
||||
// settlement landing before the first keystroke would
|
||||
// otherwise close the editor via navigate's draft reset.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
// Seed with a trailing separator so typing immediately
|
||||
// continues into child names (and prefix-filters below).
|
||||
// No listed level means nothing to seed from (the editor
|
||||
// is the recovery path for a failed home listing).
|
||||
if (parent === null) {
|
||||
setPathDraft('')
|
||||
return
|
||||
}
|
||||
const base = selected?.path ?? parent.path
|
||||
const sep = separatorOf(parent)
|
||||
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
className={css.pathInput}
|
||||
value={pathDraft}
|
||||
aria-label={t('browser.editPath')}
|
||||
autoFocus
|
||||
disabled={parentInert}
|
||||
onChange={(event) => {
|
||||
supersede()
|
||||
setLoading(false)
|
||||
// Seed with a trailing separator so typing immediately
|
||||
// continues into child names (and prefix-filters below).
|
||||
// No listed level means nothing to seed from (the editor
|
||||
// is the recovery path for a failed home listing).
|
||||
if (parent === null) {
|
||||
setPathDraft('')
|
||||
return
|
||||
}
|
||||
const base = selected?.path ?? parent.path
|
||||
const sep = separatorOf(parent)
|
||||
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
className={css.pathInput}
|
||||
value={pathDraft}
|
||||
aria-label={t('browser.editPath')}
|
||||
autoFocus
|
||||
disabled={parentInert}
|
||||
onChange={(event) => {
|
||||
// Editing the draft supersedes any in-flight navigation:
|
||||
// its completion must neither clear the newer text nor
|
||||
// repopulate the view with the older path.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
setPathDraft(event.target.value)
|
||||
}}
|
||||
{...compositionGuard}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !composingRef.current) {
|
||||
event.preventDefault()
|
||||
// Trim only detects a blank draft; the Host gets the
|
||||
// original text — a real directory name may end in
|
||||
// whitespace, and trimming would list its sibling.
|
||||
if (pathDraft.trim() !== '') navigate(pathDraft)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
cancelPathEdit()
|
||||
}
|
||||
}}
|
||||
// Focus leaving the DIALOG reads as leaving the editor and
|
||||
// cancels like Escape. Three guarded non-cancel paths: window
|
||||
// or tab focus loss (document no longer focused); a focus
|
||||
// move that stays inside the dialog card (keyboard Tab onto
|
||||
// the filtered rows or the footer toggle); and pointer paths,
|
||||
// where rows and the toggle suppress focus steal on mousedown
|
||||
// while editing so their click lands first. Enter keeps focus
|
||||
// in the input while its navigation is in flight, so a
|
||||
// submitted path is never withdrawn here.
|
||||
onBlur={(event) => {
|
||||
if (!document.hasFocus()) return
|
||||
if (event.relatedTarget instanceof HTMLElement
|
||||
&& event.relatedTarget.closest('[role="dialog"]') !== null) return
|
||||
cancelPathEdit()
|
||||
}}
|
||||
supersede()
|
||||
setLoading(false)
|
||||
setPathDraft(event.target.value)
|
||||
}}
|
||||
{...compositionGuard}
|
||||
// Escape and focus-leave cancellation live on the card-scope
|
||||
// wrapper above (they must work after focus Tabs onto the
|
||||
// rows); this handler owns only submission.
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !composingRef.current) {
|
||||
event.preventDefault()
|
||||
// Trim only detects a blank draft; the Host gets the
|
||||
// original text — a real directory name may end in
|
||||
// whitespace, and trimming would list its sibling.
|
||||
if (pathDraft.trim() !== '') navigate(pathDraft)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.content}>
|
||||
<div className={css.millerRow} ref={millerRowRef}>
|
||||
{parent !== null && (
|
||||
<LevelColumn
|
||||
entries={parent.entries}
|
||||
selectedPath={selected?.path ?? null}
|
||||
busy={parentInert}
|
||||
onPick={select}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(parent, pathDraft)}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.content}>
|
||||
<div className={css.millerRow} ref={millerRowRef}>
|
||||
{parent !== null && (
|
||||
<LevelColumn
|
||||
entries={parent.entries}
|
||||
selectedPath={selected?.path ?? null}
|
||||
busy={parentInert}
|
||||
onPick={select}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(parent, pathDraft)}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
{twoPane && <span className={css.divider} />}
|
||||
{twoPane && child !== null && (
|
||||
<LevelColumn
|
||||
entries={child.entries}
|
||||
selectedPath={null}
|
||||
busy={parentInert}
|
||||
onPick={advance}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(child, pathDraft)}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||
{/* The backend bounds a level at its complete-result limit; say so
|
||||
{twoPane && <span className={css.divider} />}
|
||||
{twoPane && child !== null && (
|
||||
<LevelColumn
|
||||
entries={child.entries}
|
||||
selectedPath={null}
|
||||
busy={parentInert}
|
||||
onPick={advance}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(child, pathDraft)}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||
{/* The backend bounds a level at its complete-result limit; say so
|
||||
* whenever a visible pane was cut instead of letting the tail of a
|
||||
* huge directory go silently missing. */}
|
||||
{(parent?.truncated === true || child?.truncated === true) && !loading
|
||||
{(parent?.truncated === true || child?.truncated === true) && !loading
|
||||
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className={css.footerBar}>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<IconPlusOutline16 size={14} />}
|
||||
disabled={parent === null || loading || parentInert || draftPending}
|
||||
onClick={() => {
|
||||
setFolderDraft('')
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
{t('browser.newFolder')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
|
||||
aria-pressed={showHidden}
|
||||
disabled={parentInert}
|
||||
// The toggle composes with the path editor (dot-led prefixes and
|
||||
// this filter interleave): while editing, don't steal focus, so
|
||||
// toggling never blur-cancels a draft mid-thought. Outside editing
|
||||
// it keeps native focus behavior.
|
||||
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
|
||||
onClick={() => { setShowHidden(prev => !prev) }}
|
||||
>
|
||||
{showHidden && <IconCheckOutline16 size={14} />}
|
||||
{t('browser.showHidden')}
|
||||
</button>
|
||||
<span className={css.footerGap} />
|
||||
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={clsx(css.footerAction)}
|
||||
disabled={targetPath === null || loading || parentInert || draftPending}
|
||||
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
|
||||
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
|
||||
>
|
||||
{t('browser.open')}
|
||||
</Button>
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className={css.footerBar}>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<IconPlusOutline16 size={14} />}
|
||||
disabled={parent === null || loading || parentInert || draftPending}
|
||||
onClick={() => {
|
||||
setFolderDraft('')
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
{t('browser.newFolder')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
|
||||
aria-pressed={showHidden}
|
||||
disabled={parentInert}
|
||||
// The toggle composes with the path editor (dot-led prefixes and
|
||||
// this filter interleave): while editing, don't steal focus, so
|
||||
// toggling never blur-cancels a draft mid-thought. Outside editing
|
||||
// it keeps native focus behavior.
|
||||
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
|
||||
onClick={() => { setShowHidden(prev => !prev) }}
|
||||
>
|
||||
{showHidden && <IconCheckOutline16 size={14} />}
|
||||
{t('browser.showHidden')}
|
||||
</button>
|
||||
<span className={css.footerGap} />
|
||||
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={clsx(css.footerAction)}
|
||||
disabled={targetPath === null || loading || parentInert || draftPending}
|
||||
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
|
||||
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
|
||||
>
|
||||
{t('browser.open')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
|
||||
<Modal
|
||||
|
||||
@@ -289,13 +289,13 @@ describe('DirectoryBrowser', () => {
|
||||
// A blur while the document itself lost focus (window switch, dev-tools
|
||||
// focus) must not discard the draft: value and filter both survive.
|
||||
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false)
|
||||
fireEvent.blur(input)
|
||||
fireEvent.focusOut(input)
|
||||
hasFocus.mockRestore()
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// A keyboard focus move that stays inside the dialog (Tab onto the
|
||||
// filtered row) keeps the draft too — the results stay reachable.
|
||||
fireEvent.blur(input, { relatedTarget: rowButton(screen.getByRole('listitem')) })
|
||||
fireEvent.focusOut(input, { relatedTarget: rowButton(screen.getByRole('listitem')) })
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
|
||||
// Toggling show-hidden mid-edit suppresses focus steal: the draft and
|
||||
// its filter survive the toggle in both directions.
|
||||
@@ -305,9 +305,31 @@ describe('DirectoryBrowser', () => {
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// Focus landing outside the dialog cancels like Escape.
|
||||
fireEvent.blur(input, { relatedTarget: document.body })
|
||||
// Focus landing outside the dialog cancels like Escape — even when the
|
||||
// departure happens from a row the user had Tabbed onto, not the input
|
||||
// (the observer lives on the card scope, not the input).
|
||||
fireEvent.focusOut(rowButton(screen.getByRole('listitem')), { relatedTarget: document.body })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// Outside editing the card-scope observer is inert.
|
||||
fireEvent.focusOut(screen.getByRole('button', { name: 'browser.showHidden' }))
|
||||
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Escape with focus on a filtered row collapses the editor, not the dialog', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${HOME}/do` } })
|
||||
// Tab parked focus on the result row; Escape must still mean "leave
|
||||
// path editing", not "close the whole dialog".
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(b.onClose).not.toHaveBeenCalled()
|
||||
// With no draft left, Escape falls through to the Modal and closes.
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
expect(b.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a picked dot-revealed hidden row stays visible as the selection', async () => {
|
||||
@@ -340,6 +362,9 @@ describe('DirectoryBrowser', () => {
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// Focus parks on the picked row (the editor's input just unmounted and
|
||||
// the Modal has no focus trap to catch a fall to body).
|
||||
expect(document.activeElement).toBe(row)
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
||||
})
|
||||
@@ -373,7 +398,7 @@ describe('DirectoryBrowser', () => {
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: '/somewhere/else' } })
|
||||
// Focus moving anywhere outside the editor abandons the draft like Escape.
|
||||
fireEvent.blur(input)
|
||||
fireEvent.focusOut(input)
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// The crumb view is back and the abandoned draft was never navigated to.
|
||||
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
|
||||
|
||||
Reference in New Issue
Block a user