Skip to content

Commit e48eb58

Browse files
committed
Respect modal keyboard focus and show Now after a calendar scroll
1 parent d077502 commit e48eb58

6 files changed

Lines changed: 59 additions & 11 deletions

File tree

‎apps/sim/platform/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Use `ActivityRow` for a first-line glyph or avatar, title/detail and centered tr
3030

3131
Measure the actual scroll viewport with `ScrollArea`, or attach `useScrollEdges` directly to a native or Base UI scroll owner. Use the shared 16px mask only on edges with more content, updating after scroll, resize, input, reset or filtering. Insets establish resting layout; they cannot substitute for an edge fade. Home widgets and notification panels use `--ui-widget-max-size` as a ceiling, not a forced height. Keep the dashboard free to scroll and let each overflowing list or schedule scroll independently. Plain tooltips preview at most three lines; retain the complete accessible text. A structured people popover owns its content and uses the dropdown’s popup padding and row insets.
3232

33-
Form dialogs use `Modal` or `MultiStepModal`. Pass a title, content, action labels and handlers. The shared borderless surface positions its title at the logical start and supplies one quiet Cancel/Back. Command search and previews use the same shadow and dismissal layer. Form controls cannot construct or style a form header, footer or Cancel button. Opening a dialog or advancing its step focuses the first available field. Confirmation dialogs focus Cancel, and read-only previews focus Close. Closing returns focus to the invoking control. Textareas scroll or grow within their owner and never show a manual resize handle. Recipient fields use the textarea’s height, type and inset. Each email tag occupies one text line with the recent row’s trailing remove action. Comma or Enter commits a draft, pasting a list commits its addresses, and Backspace from an empty draft removes the last tag.
33+
Form dialogs use `Modal` or `MultiStepModal`. Pass a title, content, action labels and handlers. The shared borderless surface positions its title at the logical start and supplies one quiet Cancel/Back. Command search and previews use the same shadow and dismissal layer. Form controls cannot construct or style a form header, footer or Cancel button. Opening a dialog or advancing its step focuses the first available field. Confirmation dialogs focus Cancel, and read-only previews focus Close. A nested dialog handles Escape one layer at a time. While a form is open, the global search shortcut leaves its draft and keyboard focus in place. Closing returns focus to the invoking control. Textareas scroll or grow within their owner and never show a manual resize handle. Recipient fields use the textarea’s height, type and inset. Each email tag occupies one text line with the recent row’s trailing remove action. Comma or Enter commits a draft, pasting a list commits its addresses, and Backspace from an empty draft removes the last tag.
3434

3535
Use `useClipboard` / `CopyButton` for successful copy feedback and `useFeedbackExpiry` for temporary status. Copied indicators and transient notices dwell for four seconds; repeated events restart the timer. Pause an actionable notice during hover or focus and keep its action available in the stack afterward. Icon color changes use `--ui-motion-color` (120ms, zero under reduced motion). Do not put literal timers or CSS durations in product components.
3636

‎apps/sim/platform/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Shared and Platform still own different recipes; `catalog/evidence.json` preserv
4141

4242
Workspace lists, sidebar items, recents and attachment batches accept typed arrays with stable item IDs. Use the named components for these collection masters: a Paper specimen's item count is sample data. An empty array renders zero items, and adding items preserves their registered row/card appearance. Each scroll viewport determines fades and controls from its actual contents. Chart labels, marks, inspected values and legends come from the same series. Supply raw numeric values and their units to data tables. The column schema determines alignment and which columns remain visible as the table narrows.
4343

44-
The full fixture includes twenty sidebar workspaces, sixteen conversations, a twelve-message inbox and a calendar that scrolls from midnight to midnight even on empty days. Home and Chat share a reading width. The resting Home greeting aligns with the sidebar Home row. The shell uses one 12px inset above the organization and on both sides of the profile row. The Home inset scrolls away through the viewport edge fade. The collapsed sidebar control aligns with the expanded toolbar. Calendar events use the muted surface. An event with a provider URL opens in a new tab. The attendee control opens its own people popover. Now appears when the current time scrolls out of view or another day is selected. It restores today’s schedule and centers the current time.
44+
The full fixture includes twenty sidebar workspaces, sixteen conversations, a twelve-message inbox and a calendar that scrolls from midnight to midnight even on empty days. Home and Chat share a reading width. The resting Home greeting aligns with the sidebar Home row. The shell uses one 12px inset above the organization and on both sides of the profile row. The Home inset scrolls away through the viewport edge fade. The collapsed sidebar control aligns with the expanded toolbar. Calendar events use the muted surface. An event with a provider URL opens in a new tab. The attendee control opens its own people popover. Now appears when the calendar scrolls away from its resting position or another day is selected. It restores today’s schedule and centers the current time. Scrolling back to that position also hides Now.
4545

4646
Start with the [component ownership map](docs/component-ownership.md) when choosing a primitive. Sidebar, recents and panel tabs use one row; form and command dialogs use one borderless surface. Copy confirmations and transient notifications last four seconds. The notification count uses the compact Invite button. Expanded notices share one subtle shadow around the list. A new notice pauses its expiry on hover or keyboard focus. Icon color transitions take 120ms and respect reduced motion. The [design contract](AGENTS.md) defines these rules.
4747

‎apps/sim/platform/app/(shell)/_components/platform-shell/platform-shell.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ export function PlatformShell({ data, children }: PlatformShellProps) {
7575
)
7676
return
7777
event.preventDefault()
78-
setCommand((previous) => !previous)
78+
const modal = event.target instanceof Element ? event.target.closest('dialog:modal') : null
79+
const canOpen = !modal || modal === drawerRef.current
80+
setCommand((open) => !open && canOpen)
7981
}
8082
document.addEventListener('keydown', shortcut)
8183
return () => document.removeEventListener('keydown', shortcut)

‎apps/sim/platform/app/(shell)/home/_components/calendar/calendar-day.tsx‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function timeSlot(startMinute: number, duration = 1) {
2020
export function CalendarDay({ calendar }: CalendarDayProps) {
2121
const [offset, setOffset] = useState(0)
2222
const [returnCount, setReturnCount] = useState(0)
23-
const [timeVisible, setTimeVisible] = useState(true)
23+
const [atNow, setAtNow] = useState(true)
2424
const viewport = useRef<HTMLDivElement>(null)
2525
const now = useRef<HTMLSpanElement>(null)
2626
const scrollEdges = useScrollEdges('vertical', { viewport, tabStop: true })
@@ -29,12 +29,25 @@ export function CalendarDay({ calendar }: CalendarDayProps) {
2929
const marker = now.current
3030
const root = viewport.current
3131
if (!marker || !root) return
32-
root.scrollTop = marker.offsetTop + (marker.offsetHeight - root.clientHeight) / 2
33-
const observer = new IntersectionObserver(([entry]) => setTimeVisible(entry.isIntersecting), {
34-
root,
35-
})
36-
observer.observe(marker)
37-
return () => observer.disconnect()
32+
let restingTop = root.scrollTop
33+
const atRest = () => Math.abs(root.scrollTop - restingTop) <= 1
34+
const updatePosition = () => setAtNow(atRest())
35+
const measure = () => {
36+
const keepCentered = atRest()
37+
const centered = marker.offsetTop + (marker.offsetHeight - root.clientHeight) / 2
38+
restingTop = Math.max(0, Math.min(centered, root.scrollHeight - root.clientHeight))
39+
if (keepCentered) root.scrollTop = restingTop
40+
updatePosition()
41+
}
42+
measure()
43+
const observer = new ResizeObserver(measure)
44+
observer.observe(root)
45+
if (marker.parentElement) observer.observe(marker.parentElement)
46+
root.addEventListener('scroll', updatePosition, { passive: true })
47+
return () => {
48+
observer.disconnect()
49+
root.removeEventListener('scroll', updatePosition)
50+
}
3851
}, [calendar.date, calendar.nowMinute, offset, returnCount])
3952

4053
useLayoutEffect(() => {
@@ -55,7 +68,7 @@ export function CalendarDay({ calendar }: CalendarDayProps) {
5568
<div className={styles.toolbar}>
5669
<span data-type='T052'>{dayLabel}</span>
5770
<div className={styles.dayControls}>
58-
{(offset !== 0 || !timeVisible) && (
71+
{(offset !== 0 || !atNow) && (
5972
<Button variant='quiet' size='compact' onClick={returnToNow}>
6073
Now
6174
</Button>

‎apps/sim/platform/checks/conversation.spec.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ test('Now restores today’s time after scrolling the calendar or viewing anothe
4848
await schedule.scrollIntoViewIfNeeded()
4949
await expect(currentTime).toBeInViewport()
5050
await expect(now).toHaveCount(0)
51+
const restingTop = await schedule.evaluate((el) => el.scrollTop)
52+
await schedule.evaluate((el) => el.scrollBy(0, 12))
53+
await expect(currentTime).toBeInViewport()
54+
await expect(now).toBeVisible()
55+
await schedule.evaluate((el, top) => el.scrollTo(0, top), restingTop)
56+
await expect(now).toHaveCount(0)
5157
await schedule.press('End')
5258
await expect(now).toBeVisible()
5359
await now.press('Enter')

‎apps/sim/platform/checks/shell.spec.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@ test('the sidebar keeps its reading position across route changes and a Settings
6161
await expect.poll(() => nav.evaluate((element) => element.scrollTop)).toBe(offset)
6262
})
6363

64+
test('command search respects an open form and toggles within the navigation drawer', async ({
65+
page,
66+
}) => {
67+
await page.setViewportSize({ width: 390, height: 800 })
68+
await page.goto(`${origin}/home?state=full`)
69+
await page.getByRole('button', { name: 'Expand sidebar' }).click()
70+
const drawer = page.getByRole('dialog', { name: 'Navigation drawer' })
71+
const organization = drawer.getByRole('button', { name: 'Organization menu', exact: true })
72+
await organization.click()
73+
await drawer.getByRole('button', { name: 'Invite', exact: true }).click()
74+
const input = page.getByRole('textbox', { name: 'Email addresses' })
75+
const search = page.getByRole('combobox', { name: 'Search the platform' })
76+
await input.fill('member')
77+
await input.press('ControlOrMeta+k')
78+
await expect(search).toBeHidden()
79+
await expect(input).toBeFocused()
80+
await page.keyboard.type('@example.test')
81+
await expect(input).toHaveValue('member@example.test')
82+
await page.keyboard.press('Escape')
83+
await expect(organization).toBeFocused()
84+
await page.keyboard.press('ControlOrMeta+k')
85+
await expect(search).toBeFocused()
86+
await search.press('ControlOrMeta+k')
87+
await expect(search).toBeHidden()
88+
await expect(organization).toBeFocused()
89+
})
90+
6491
test('a keyboard search navigates to the matching chat and shell edits survive the return trip', async ({
6592
page,
6693
}) => {

0 commit comments

Comments
 (0)