feat(client): localize composer hints and rework input command interaction
Unify the /plan claimed hint with the plan placeholder through a locale namespace, localize slash menu group titles, replace the PermissionSelect native select with the Menu primitive, add a goal pause verb chain, clamp anchored popups to the viewport with scroll-into-view and outside-dismiss, and fix onPasteUpgrade insertedRange to account for the chip trailing gap.
This commit is contained in:
@@ -24,7 +24,8 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -37,14 +38,18 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
z-index: 100;
|
||||
min-width: 260px;
|
||||
max-width: 537px;
|
||||
/* Height cap: the 320px design maximum, clamped at runtime to the space
|
||||
* above the composer (inline max-height set in MenuView.tsx). */
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
@@ -26,6 +28,13 @@
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -75,6 +84,15 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Heading row above a source group: non-interactive small grey text,
|
||||
* padding aligned with items (mirrors ui-primitives Menu .label). */
|
||||
.groupTitle {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Pending-source row: same cell metrics, dimmed label. */
|
||||
.loading {
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Trigger candidate menu: renders the SlashService menu store into the
|
||||
* conversation.input.overlay anchor. Closed state renders null (the overlay
|
||||
* slot stays mounted); groups render in roster order, pending groups as a
|
||||
* loading row; pointer picks route back through the service (combobox
|
||||
* pattern — focus never leaves the textarea, so rows are mousedown-handled
|
||||
* and the highlight is exposed via aria-activedescendant on the listbox).
|
||||
* slot stays mounted); groups render in roster order under localized title
|
||||
* rows, pending groups as a loading row; pointer picks route back through
|
||||
* the service (combobox pattern — focus never leaves the textarea, so rows
|
||||
* are mousedown-handled and the highlight is exposed via
|
||||
* aria-activedescendant on the listbox).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './MenuView.module.css'
|
||||
import type { MenuViewInjected } from './slots.ts'
|
||||
|
||||
/** Design cap on the list height (figma SLASH 39:26572 MenuDropdown). */
|
||||
const MAX_HEIGHT = 320
|
||||
|
||||
/** DOM id of one option row (the aria-activedescendant target). */
|
||||
function optionId(source: string, index: number): string {
|
||||
return `dsh-slash-option-${source}-${index}`
|
||||
@@ -18,49 +23,86 @@ function optionId(source: string, index: number): string {
|
||||
|
||||
/**
|
||||
* Render the candidate menu overlay entry.
|
||||
* @param props - injected face: the menu store and the pick route.
|
||||
* @param props - injected face: the menu store, the pick route, and the menu-namespace translator.
|
||||
* @returns the dropdown while open; null while closed.
|
||||
*/
|
||||
export function MenuView({ menu, onPick }: MenuViewInjected) {
|
||||
export function MenuView({ menu, onPick, onDismiss, t }: MenuViewInjected) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => menu.subscribe(fn),
|
||||
() => menu.getSnapshot(),
|
||||
)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The list is bottom-anchored above the composer; clamp the design cap to
|
||||
// the space above it, re-measured on every store update (the anchor moves
|
||||
// when the composer grows).
|
||||
const maxHeight = useAnchoredMaxHeight(listRef, MAX_HEIGHT, state)
|
||||
const highlight = state.open ? state.highlight : null
|
||||
// Focus stays in the textarea (combobox pattern), so the browser never
|
||||
// scrolls the active option into view on keyboard moves — do it here.
|
||||
useEffect(() => {
|
||||
if (highlight === null) return
|
||||
document.getElementById(optionId(highlight.source, highlight.index))
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlight])
|
||||
// Dismiss on pointer outside the menu AND outside the composer card
|
||||
// (clicking the textarea or bottom bar must not close the menu).
|
||||
useEffect(() => {
|
||||
if (!state.open) return
|
||||
const onPointerDown = (ev: PointerEvent): void => {
|
||||
if (!(ev.target instanceof Node)) return
|
||||
if (listRef.current?.contains(ev.target)) return
|
||||
const composerCard = listRef.current?.closest('[data-composer-card]')
|
||||
if (composerCard?.contains(ev.target)) return
|
||||
onDismiss()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
|
||||
}, [state.open, onDismiss])
|
||||
if (!state.open) return null
|
||||
const { highlight } = state
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={css.menu}
|
||||
style={{ maxHeight }}
|
||||
role="listbox"
|
||||
aria-label="Trigger suggestions"
|
||||
aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
|
||||
>
|
||||
{state.groups.map(group => group.status === 'pending'
|
||||
? <div key={group.source} className={css.loading} data-source={group.source}>Loading {group.source}…</div>
|
||||
: group.items.map((item, index) => {
|
||||
const active = highlight !== null && highlight.source === group.source && highlight.index === index
|
||||
return (
|
||||
<button
|
||||
key={`${group.source}:${item.name}`}
|
||||
id={optionId(group.source, index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={clsx(css.item, active && css.active)}
|
||||
// mousedown, not click: the textarea keeps focus (combobox
|
||||
// pattern) — preventing default stops the focus steal, and the
|
||||
// pick runs before any blur-driven teardown.
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault()
|
||||
onPick(group.source, index)
|
||||
}}
|
||||
>
|
||||
{item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
|
||||
<span className={css.itemName}>{item.name}</span>
|
||||
{item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
|
||||
</button>
|
||||
)
|
||||
}))}
|
||||
<div className={css.viewport}>
|
||||
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0)
|
||||
? null
|
||||
: (
|
||||
<Fragment key={group.source}>
|
||||
<div className={css.groupTitle} role="presentation" data-source={group.source}>{t(group.source)}</div>
|
||||
{group.status === 'pending'
|
||||
? <div className={css.loading} data-source={group.source}>{t('loading')}</div>
|
||||
: group.items.map((item, index) => {
|
||||
const active = highlight !== null && highlight.source === group.source && highlight.index === index
|
||||
return (
|
||||
<button
|
||||
key={`${group.source}:${item.name}`}
|
||||
id={optionId(group.source, index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={clsx(css.item, active && css.active)}
|
||||
// mousedown, not click: the textarea keeps focus (combobox
|
||||
// pattern) — preventing default stops the focus steal, and the
|
||||
// pick runs before any blur-driven teardown.
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault()
|
||||
onPick(group.source, index)
|
||||
}}
|
||||
>
|
||||
{item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
|
||||
<span className={css.itemName}>{item.name}</span>
|
||||
{item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,6 +256,13 @@ export class SlashController {
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/** External dismiss (e.g. pointer outside the composer area). */
|
||||
dismiss(): void {
|
||||
if (this.disposed) return
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
}
|
||||
|
||||
/** Scope teardown: close and abort (the service deletes the map entry). */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* self-registers into the conversation.input.overlay slot. Frozen pipeline
|
||||
* contract in ./contract.ts; sources register through ctx.slash alone.
|
||||
*/
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from './service.ts'
|
||||
import type { MenuViewInjected } from './slots.ts'
|
||||
@@ -29,8 +31,11 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: controller resolution reads the session scope tree. */
|
||||
export const inject = ['sessions']
|
||||
/** Namespace owning the candidate-menu copy: group titles keyed by source name plus the pending row. */
|
||||
const MENU_NS = 'slash.menu'
|
||||
|
||||
/** Required services: controller resolution reads the session scope tree; the menu copy is localized. */
|
||||
export const inject = ['sessions', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register MenuView into the
|
||||
@@ -39,6 +44,13 @@ export const inject = ['sessions']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(SlashService)
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(MENU_NS, 'zh', { command: '命令', skill: '技能', subagent: '子智能体', loading: '正在加载…' }),
|
||||
ctx.locale.register(MENU_NS, 'en', { command: 'Commands', skill: 'Skills', subagent: 'Subagents', loading: 'Loading…' }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-slash: menu dictionaries')
|
||||
// Conditional mount: 'conversation.input.overlay' is declared by the
|
||||
// conversation composer entry, and the conversation service is mounted
|
||||
// after that declaration lands on the ledger — its presence is the
|
||||
@@ -59,6 +71,8 @@ export function apply(ctx: ClientContext): void {
|
||||
return {
|
||||
menu: controller.menu,
|
||||
onPick: (source, index) => { controller.pick(source, index) },
|
||||
onDismiss: () => { controller.dismiss() },
|
||||
t: scope.locale.bind(MENU_NS),
|
||||
}
|
||||
},
|
||||
}, MenuView), 'ui-slash: MenuView overlay registration')
|
||||
|
||||
@@ -87,7 +87,7 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
actx,
|
||||
sessionId: id,
|
||||
roster: {
|
||||
sources: trigger => live.sources.filter(s => s.trigger === trigger),
|
||||
sources: trigger => live.sources.filter(s => s.trigger === trigger).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
all: () => live.sources,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
// Type-only edge: the SlotMap augmentation below merges into this package's interface.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MenuState } from '../core/contract.ts'
|
||||
|
||||
@@ -35,4 +36,12 @@ export interface MenuViewInjected {
|
||||
* @param index - candidate index within the group.
|
||||
*/
|
||||
onPick: (source: string, index: number) => void
|
||||
/** Dismiss the menu (external pointer outside the composer area). */
|
||||
onDismiss: () => void
|
||||
/**
|
||||
* Bound translator for the menu namespace: group titles keyed by source
|
||||
* name (the locale fallback chain returns the key itself, so an unknown
|
||||
* source shows its raw name) plus the pending-row text.
|
||||
*/
|
||||
t: Translate
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ export interface SlashSource {
|
||||
readonly trigger: TriggerChar
|
||||
/** Menu group label; unique per trigger — duplicate registration throws. */
|
||||
readonly name: string
|
||||
/** Menu group display order (lower = higher in the list; default 0). */
|
||||
readonly order?: number
|
||||
candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]>
|
||||
/** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */
|
||||
onPick(pick: SlashPick): PickOutcome
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -31,12 +32,25 @@ async function bench() {
|
||||
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
return { ctx, slots }
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots, locale }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the sessions dependency (controller resolution reads the scope tree)', () => {
|
||||
expect(inject).toEqual(['sessions'])
|
||||
it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
|
||||
expect(inject).toEqual(['sessions', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
|
||||
const { ctx, locale } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const t = locale.bind('slash.menu')
|
||||
expect(t('command')).toBe('命令')
|
||||
locale.setLocale('en')
|
||||
expect(t('skill')).toBe('Skills')
|
||||
expect(t('subagent')).toBe('Subagents')
|
||||
expect(t('loading')).toBe('Loading…')
|
||||
})
|
||||
|
||||
it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => {
|
||||
@@ -65,6 +79,8 @@ describe('apply', () => {
|
||||
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
|
||||
)
|
||||
expect(injected.menu).toBe(controller.menu)
|
||||
// The injected translator is the menu-namespace binding.
|
||||
expect(injected.t('command')).toBe('命令')
|
||||
// The pick face routes into the controller pipeline (closed menu → no-op).
|
||||
injected.onPick('command', 0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MenuView rendering spec, props-direct (slot-parity doctrine): closed store
|
||||
* renders null, groups render in roster order with pending rows as loading,
|
||||
* pointer picks route (source, index) back without stealing focus, and the
|
||||
* highlight is exposed through aria-activedescendant + aria-selected.
|
||||
* renders null, groups render in roster order under localized title rows
|
||||
* (unknown sources fall back to the raw name) with pending rows as loading,
|
||||
* pointer picks route (source, index) back without stealing focus, the
|
||||
* highlight is exposed through aria-activedescendant + aria-selected, and
|
||||
* the list height clamps to the space above the composer.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -34,13 +36,35 @@ function openState(partial?: Partial<MenuState>): MenuState {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
// jsdom has no scrollIntoView; the view calls it on the highlighted option.
|
||||
const scrollIntoView = vi.fn()
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Dictionary-backed fake mirroring the LocaleService key fallback (an
|
||||
// unknown key comes back verbatim, so unknown sources show their raw name).
|
||||
const DICT: Record<string, string> = { command: 'Commands', skill: 'Skills', loading: 'Loading…' }
|
||||
const t = (key: string) => DICT[key] ?? key
|
||||
|
||||
function mount(state: MenuState) {
|
||||
const menu = createSnapshotStore<MenuState>(state)
|
||||
const onPick = vi.fn()
|
||||
const view = render(<MenuView menu={menu} onPick={onPick} />)
|
||||
return { menu, onPick, view }
|
||||
const onDismiss = vi.fn()
|
||||
const view = render(<MenuView menu={menu} onPick={onPick} onDismiss={onDismiss} t={t} />)
|
||||
return { menu, onPick, onDismiss, view }
|
||||
}
|
||||
|
||||
/** The non-interactive group title rows (role=presentation), in document order. */
|
||||
function titles(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('div[role="presentation"][data-source]')]
|
||||
.map(el => el.textContent ?? '')
|
||||
}
|
||||
|
||||
describe('MenuView', () => {
|
||||
@@ -57,7 +81,19 @@ describe('MenuView', () => {
|
||||
mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
|
||||
expect(screen.queryByText('Loading skill…')).not.toBeNull()
|
||||
expect(screen.queryByText('Loading…')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
|
||||
const { view } = mount(openState({
|
||||
groups: [
|
||||
{ source: 'command', status: 'ready', items: [{ name: 'goal' }] },
|
||||
{ source: 'hollow', status: 'ready', items: [] },
|
||||
{ source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
],
|
||||
}))
|
||||
expect(titles(view.container)).toEqual(['Commands', 'mystery', 'Skills'])
|
||||
})
|
||||
|
||||
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
|
||||
@@ -75,6 +111,37 @@ describe('MenuView', () => {
|
||||
expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
|
||||
})
|
||||
|
||||
it('scrolls the highlighted option into view when the highlight moves', () => {
|
||||
const { menu } = mount(openState())
|
||||
scrollIntoView.mockClear()
|
||||
act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
|
||||
})
|
||||
|
||||
it('caps the list height at the design maximum when the composer sits low enough', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
})
|
||||
|
||||
it('clamps the list height to the space above the composer minus the safe margin', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('re-fits the height when the window resizes', () => {
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
rect.mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
rect.mockReturnValue({ bottom: 100 } as DOMRect)
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
|
||||
})
|
||||
|
||||
it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
|
||||
const { onPick } = mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user