Skip to content

Commit d7ae1eb

Browse files
committed
fix(docs): polish responsive controls and keep search indexing current
1 parent f54d9f5 commit d7ae1eb

7 files changed

Lines changed: 142 additions & 43 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,8 @@ jobs:
11501150
filters: |
11511151
docs:
11521152
- 'apps/docs/content/**'
1153+
- 'apps/docs/components/workflow-preview/examples.ts'
1154+
- 'apps/docs/components/workflow-preview/block-display-workflows.ts'
11531155
- 'apps/docs/scripts/compile-content.ts'
11541156
- 'apps/docs/scripts/index-search.ts'
11551157
- 'apps/docs/lib/content/**'

‎apps/docs/EDITORIAL_GUIDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ A link replaces repeated detail only after the owner contains it. Keep a concise
6363
- On large integration references, keep action and trigger descriptions visible and fold their field tables into `ReferenceDetails`. Preserve headings inside each disclosure so direct links and browser search can reveal them. Setup requirements and consequential warnings stay outside the folded tables.
6464
- Integration logos belong beside the page title, using the generated `integration` frontmatter. Keep the regular docs header actions and do not repeat the logo in the article body.
6565
- Match inline code to Sim Chat: normal-weight monospace text, tight padding, and the existing borderless filled surface tokens. Scope the treatment to prose code; preserve fenced examples, API route bars, schema controls, and selector labels.
66-
- Use shared `ChipSelect` controls for API response and request-example pickers, with accessible names that include the current value, checked radio semantics for single choices, and menus aligned to their triggers. Keep docs pickers nonmodal so they do not hide lazily hydrating siblings. Keep response headings free of inherited extra margins. Scope dependency filter styling to `data-object-search-input`, including schema popovers portaled to the body; focus stays inside the field, and touch fields retain the shared target and text sizes.
66+
- Use the shared `TabsSelect` control for API response and request-example pickers, with accessible names that include the current value, checked radio semantics for single choices, and menus aligned to their triggers. Keep docs pickers nonmodal so they do not hide lazily hydrating siblings. Keep response headings free of inherited extra margins. Scope dependency filter styling to `data-object-search-input`, including schema popovers portaled to the body; focus stays inside the field, and touch fields retain the shared target and text sizes.
6767
- Import authored tabs from `@/components/ui/tabs`. Mixed panels retain a content inset; only a single code fence fills the frame. Keep long tab lists horizontally scrollable and long prose references wrapped. For bookmarked panels, use explicit native `TabsList`/`TabsTrigger` controls with `aria-controls` matching each panel ID; changing a panel ID alone leaves the generated control pointing at a nonexistent element.
6868
- Keep a screenshot when it locates a difficult control or shows a meaningful result. Keep a video when motion explains behavior that text or a static example cannot. Remove decorative, redundant, misleading, or non-rendering media.
6969
- Capture the current product with a small, neutral example. Use the shared `Image` component: `overview` for a workspace or full settings panel, `detail` for a complete dialog or focused control, and `result` for an output. Keep the branded background in both the page and media viewer. Overview and result images extend to the frame's bottom edge, with scenery above and beside the window. Crop empty canvas at the source; never cut off a control or result needed by the instructions. Keep complete dialogs inset so their actions remain visible.

‎apps/docs/components/docs/sample-panels.tsx‎

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client'
22

33
import { type ReactNode, useId, useState } from 'react'
4-
import { ChevronDown } from '@sim/emcn/icons'
54
import { cn } from '@/components/docs/classnames'
65
import { CodeContent } from '@/components/ui/code-block'
76
import { CopyButton } from '@/components/ui/copy-button'
@@ -51,7 +50,9 @@ export function SamplePanels({
5150
className={cn('my-0 overflow-hidden rounded-lg bg-[var(--docs-surface)]', className)}
5251
>
5352
<div className='flex min-h-11 items-center gap-2 px-3'>
54-
<span className='shrink-0 font-medium text-docs-control'>{label}</span>
53+
<span id={selectorId} className='shrink-0 font-medium text-docs-control'>
54+
{label}
55+
</span>
5556
{presentation === 'tabs' ? (
5657
<TabsList aria-label={`${label} language`}>
5758
{items.map((item) => (
@@ -61,21 +62,7 @@ export function SamplePanels({
6162
))}
6263
</TabsList>
6364
) : (
64-
<div className='relative min-w-0'>
65-
<TabsSelect
66-
id={selectorId}
67-
aria-label={`${label} status`}
68-
aria-controls={current.id ?? `${selectorId}-${current.value}`}
69-
className='h-7 pointer-coarse:min-h-11 w-full cursor-pointer appearance-none rounded-lg bg-transparent py-1 pr-6 pl-2 text-docs-control hover:bg-[var(--docs-hover)] focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2'
70-
>
71-
{items.map((item) => (
72-
<option key={item.value} value={item.value}>
73-
{item.label}
74-
</option>
75-
))}
76-
</TabsSelect>
77-
<ChevronDown className='-translate-y-1/2 pointer-events-none absolute top-1/2 right-1 size-4 text-[var(--docs-muted)]' />
78-
</div>
65+
<TabsSelect label={`${label} example`} items={items} />
7966
)}
8067
<div className='ml-auto shrink-0'>
8168
<CopyButton text={current.copyText} label={`Copy ${label.toLowerCase()}`} />

‎apps/docs/components/docs/search.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,10 @@ export function DocsSearchTrigger({ compact = false }: DocsSearchTriggerProps) {
104104
aria-haspopup='dialog'
105105
onClick={(event) => show?.(event.currentTarget)}
106106
className={cn(
107-
'flex items-center gap-1.5 rounded-lg text-[var(--docs-muted)] text-docs-nav transition-colors hover:bg-[var(--docs-hover)] focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2 active:bg-[var(--docs-pressed)]',
107+
'flex items-center gap-1.5 rounded-lg text-[var(--docs-muted)] text-docs-nav focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2',
108108
compact
109-
? 'size-11 justify-center'
110-
: 'h-[30px] w-full border border-[var(--docs-border)] bg-[var(--docs-reading)] px-[7px]'
109+
? 'before:-z-10 relative isolate size-11 justify-center before:absolute before:inset-1.5 before:rounded-lg before:transition-colors hover:before:bg-[var(--docs-hover)] active:before:bg-[var(--docs-pressed)] motion-reduce:before:transition-none'
110+
: 'h-[30px] w-full border border-[var(--docs-border)] bg-[var(--docs-reading)] px-[7px] transition-colors hover:bg-[var(--docs-hover)] active:bg-[var(--docs-pressed)] motion-reduce:transition-none'
111111
)}
112112
>
113113
<Search className='size-4 shrink-0' aria-hidden />
@@ -193,6 +193,7 @@ function SearchContents({ close, navigation, sectionLabel }: SearchContentsProps
193193
setSelected(0)
194194
}}
195195
onKeyDown={(event) => {
196+
if (event.nativeEvent.isComposing) return
196197
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
197198
event.preventDefault()
198199
if (results.length)

‎apps/docs/components/docs/table-of-contents.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo
5656
</nav>
5757
</details>
5858
) : (
59-
<nav aria-label='On this page' className='pl-2'>
59+
<nav aria-label='On this page' className='px-2'>
6060
<h2 className='mb-4 font-medium text-[var(--docs-heading)] text-docs-control leading-5'>
6161
On this page
6262
</h2>

‎apps/docs/components/ui/tabs.tsx‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import {
1313
useRef,
1414
useState,
1515
} from 'react'
16+
import * as Dropdown from '@radix-ui/react-dropdown-menu'
1617
import * as RadixTabs from '@radix-ui/react-tabs'
18+
import { Check, ChevronDown } from '@sim/emcn/icons'
1719
import { cn } from '@/components/docs/classnames'
20+
import { docsControlClass, docsMenuClass, docsMenuItemClass } from '@/components/docs/primitives'
1821

1922
interface TabContextValue {
2023
baseId: string
@@ -195,20 +198,55 @@ export function Tabs({
195198

196199
interface TabsListProps extends ComponentPropsWithoutRef<typeof RadixTabs.List> {}
197200

198-
interface TabsSelectProps extends ComponentPropsWithoutRef<'select'> {}
201+
interface TabsSelectProps
202+
extends Omit<ComponentPropsWithoutRef<typeof Dropdown.Trigger>, 'children'> {
203+
items: { value: string; label: string }[]
204+
label: string
205+
}
199206

200-
export function TabsSelect({ onChange, ...props }: TabsSelectProps) {
207+
export function TabsSelect({ items, label, className, ...props }: TabsSelectProps) {
201208
const context = useContext(TabContext)
202209
if (!context) throw new Error('TabsSelect must be inside Tabs')
210+
const selected = items.find((item) => item.value === context.value)
203211
return (
204-
<select
205-
{...props}
206-
value={context.value}
207-
onChange={(event) => {
208-
context.select(event.target.value)
209-
onChange?.(event)
210-
}}
211-
/>
212+
<Dropdown.Root modal={false}>
213+
<Dropdown.Trigger
214+
{...props}
215+
aria-label={`${label}: ${selected?.label ?? ''}`}
216+
className={cn(docsControlClass, 'min-w-0 max-w-full shrink px-2', className)}
217+
>
218+
<span className='truncate'>{selected?.label}</span>
219+
<ChevronDown className='size-4 shrink-0 text-[var(--docs-muted)]' aria-hidden />
220+
</Dropdown.Trigger>
221+
<Dropdown.Portal>
222+
<Dropdown.Content
223+
align='start'
224+
sideOffset={6}
225+
collisionPadding={12}
226+
className={cn(
227+
'docs-root z-[100] max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[var(--radix-dropdown-menu-trigger-width)] max-w-[min(28rem,var(--radix-dropdown-menu-content-available-width))] overflow-y-auto overscroll-contain',
228+
docsMenuClass
229+
)}
230+
>
231+
<Dropdown.RadioGroup value={context.value} onValueChange={context.select}>
232+
{items.map((item) => (
233+
<Dropdown.RadioItem
234+
key={item.value}
235+
value={item.value}
236+
className={cn(docsMenuItemClass, 'pointer-coarse:min-h-11')}
237+
>
238+
<span className='size-4 shrink-0'>
239+
<Dropdown.ItemIndicator>
240+
<Check className='size-4' aria-hidden />
241+
</Dropdown.ItemIndicator>
242+
</span>
243+
<span className='min-w-0 break-words'>{item.label}</span>
244+
</Dropdown.RadioItem>
245+
))}
246+
</Dropdown.RadioGroup>
247+
</Dropdown.Content>
248+
</Dropdown.Portal>
249+
</Dropdown.Root>
212250
)
213251
}
214252

‎apps/docs/scripts/test-navigation-e2e.ts‎

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,66 @@ try {
743743
}
744744
}
745745

746+
for (const colorScheme of ['light', 'dark'] as const) {
747+
for (const width of [320, 768, 1440]) {
748+
const context = await browser.newContext({ viewport: { width, height: 900 }, colorScheme })
749+
try {
750+
await check(`API example menu: ${colorScheme} at ${width}px`, async () => {
751+
const page = await context.newPage()
752+
await open(page, '/api-reference/workflows/listWorkflows')
753+
const trigger = page.getByRole('button', { name: /^Response example:/ })
754+
await trigger.scrollIntoViewIfNeeded()
755+
const selectedRegion = page.getByRole('region', { name: 'Response', exact: true })
756+
const initialPanel = await selectedRegion.getAttribute('id')
757+
await trigger.focus()
758+
await page.keyboard.press('Enter')
759+
const menu = page.getByRole('menu')
760+
await expect(menu).toBeVisible()
761+
await expect(menu).toHaveAttribute(
762+
'aria-labelledby',
763+
(await trigger.getAttribute('id')) ?? ''
764+
)
765+
await expect(trigger).toHaveAttribute(
766+
'aria-controls',
767+
(await menu.getAttribute('id')) ?? ''
768+
)
769+
await expect(page.getByRole('menuitemradio', { name: /^200/ })).toHaveAttribute(
770+
'aria-checked',
771+
'true'
772+
)
773+
const bounds = await menu.boundingBox()
774+
if (!bounds) throw new Error('API example menu has no bounds')
775+
expect(bounds.x).toBeGreaterThanOrEqual(0)
776+
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width)
777+
expect(bounds.y + bounds.height).toBeLessThanOrEqual(900)
778+
await page.getByRole('menuitemradio', { name: '400', exact: true }).focus()
779+
await page.keyboard.press('Enter')
780+
await expect(menu).toHaveCount(0)
781+
await expect(trigger).toBeFocused()
782+
await expect(trigger).toHaveAccessibleName('Response example: 400')
783+
const selectedPanel = await selectedRegion.getAttribute('id')
784+
if (!initialPanel || !selectedPanel) throw new Error('API examples have no panel IDs')
785+
await expect(page.locator(`[id="${initialPanel}"]`)).toBeHidden()
786+
await expect(page.locator(`[id="${selectedPanel}"]`)).toBeVisible()
787+
await trigger.click()
788+
await expect(
789+
page.getByRole('menuitemradio', { name: '400', exact: true })
790+
).toHaveAttribute('aria-checked', 'true')
791+
await page.screenshot({
792+
path: join(directory, `api-example-menu-${colorScheme}-${width}.png`),
793+
})
794+
await page.keyboard.press('Escape')
795+
await expect(trigger).toBeFocused()
796+
expect(
797+
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)
798+
).toBe(true)
799+
})
800+
} finally {
801+
await context.close()
802+
}
803+
}
804+
}
805+
746806
const languageContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
747807
try {
748808
await check('API language preference survives navigation and reload', async () => {
@@ -792,17 +852,14 @@ try {
792852
const page = await languageContext.newPage()
793853
try {
794854
await open(page, '/api-reference/workflows/executeWorkflowV2')
795-
const language = page.getByRole('combobox', { name: 'Request status', exact: true })
796-
const python = await language
797-
.getByRole('option', { name: /^Python/ })
855+
const language = page.getByRole('button', { name: /^Request example:/ })
856+
await language.click()
857+
await page
858+
.getByRole('menuitemradio', { name: /^Python/ })
798859
.first()
799-
.getAttribute('value')
800-
if (!python) throw new Error('Execute workflow has no Python request example')
801-
await language.selectOption(python)
802-
await expect(language).toHaveValue(python)
803-
const requestId = await language.getAttribute('aria-controls')
804-
if (!requestId) throw new Error('Selected request has no sample panel')
805-
const request = page.locator(`[id="${requestId}"] code`)
860+
.click()
861+
await expect(language).toHaveAccessibleName(/^Request example: Python/)
862+
const request = page.getByRole('region', { name: 'Request', exact: true }).locator('code')
806863
const originalSample = await request.textContent()
807864
const formats = page.getByRole('tablist', {
808865
name: '200 response content type',
@@ -822,7 +879,7 @@ try {
822879
if (!panelId) throw new Error(`Response format ${format} has no panel`)
823880
const panel = page.locator(`[id="${panelId}"]`)
824881
await expect(panel).toBeVisible()
825-
await expect(language).toHaveValue(python)
882+
await expect(language).toHaveAccessibleName(/^Request example: Python/)
826883
await expect(request).toHaveText(originalSample ?? '')
827884
const stringTypeVisible = await panel
828885
.getByText('string', { exact: true })
@@ -989,6 +1046,14 @@ try {
9891046
await expect(page.getByRole('button', { name: 'Menu', exact: true })).toBeFocused()
9901047
}
9911048
if (width >= 1280) {
1049+
const outlineContainer = page.getByRole('complementary').filter({
1050+
has: page.getByRole('navigation', { name: 'On this page', exact: true }),
1051+
})
1052+
expect(
1053+
await outlineContainer.evaluate(
1054+
(element) => element.scrollWidth <= element.clientWidth
1055+
)
1056+
).toBe(true)
9921057
const outline = await page
9931058
.getByRole('navigation', { name: 'On this page', exact: true })
9941059
.filter({ visible: true })
@@ -1175,6 +1240,12 @@ try {
11751240
await search.fill('Slack')
11761241
await expect(page.getByRole('option').first()).toBeVisible()
11771242
await page.screenshot({ path: join(directory, 'search-results.png') })
1243+
const activeResult = await search.getAttribute('aria-activedescendant')
1244+
await search.dispatchEvent('keydown', { key: 'ArrowDown', isComposing: true })
1245+
await expect(search).toHaveAttribute('aria-activedescendant', activeResult ?? '')
1246+
await search.dispatchEvent('keydown', { key: 'Enter', isComposing: true })
1247+
await expect(page.getByRole('dialog')).toBeVisible()
1248+
await expect(page).toHaveURL(new URL('/introduction', target).href)
11781249
await search.press('ArrowDown')
11791250
await search.press('Enter')
11801251
await expect(page).not.toHaveURL(new URL('/introduction', target).href)

0 commit comments

Comments
 (0)