feat: integration search bar

This commit is contained in:
Florian Sylvain committed 2026-10-25 21:57:43 +01:00
1 parent a8fea8d900
commit 25d9c30b8a
22 files changed
+2558 -41

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0.
## Boards
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`.
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`.
- `RenewCMS Screens Auth.dc.html` (7 groups, 50 states), `RenewCMS Screens API keys.dc.html` (12 states).
- `RenewCMS Prototype.dc.html`: updated to match (title-only New article, no body limit or counter, neutral count, pagination, link button, AppFrame's mobile drawer).
- Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
+48
View File
@@ -114,3 +114,51 @@ New group "Search and sharing" after Articles, in the sidebar index and the mobi
- GET /v1/article/{id}/seo: the resolved SEO object. Fields: title, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks, json_ld. Drafts answer 404 to anonymous callers.
- GET /v1/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. Errors: 400 "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes; bad `page` or `size`), 401, 404 `site_not_found` (unknown site key; this is the body the API returns, shown as a code and not rewritten as prose).
PATCH /v1/article/{id} lists the new optional fields (slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed). The 403 row of the HTTP status table adds: site SEO defaults are owner only; article SEO fields need only the right to edit the article.
## Search (jump-to palette)
Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×844, light and dark. Search never hides or filters the page. It only jumps, so find-in-page and deep links keep working.
### Behaviour
- Trigger, desktop: a field-style button in the page header, right of the title block, 320×36. Placeholder "Search the API reference…", "/" key hint. Mobile: 44×44 icon button right of "Jump to", inside the sticky bar. Opens with "/" (when focus is not in an input), Ctrl+K and Cmd+K. Both shortcuts are announced with aria-keyshortcuts.
- Palette: modal, 720 px wide, 96 px from the top, on the existing overlay. Mobile: full-screen sheet with a "Cancel" button (44 px) in the input row. Focus goes to the input; on close it returns to the trigger.
- Groups: "Endpoints" (max 6 rows, then "N more endpoints. Keep typing to narrow the list."), then "Sections". Empty query: one group "Suggested" with 5 rows.
- Match: every space-separated term must appear (case-insensitive substring) in method, path, description or access chip, or in a request field name, or in an error code or message. Rows matched only in a field or error show a second line ("Body field", "Query parameter" or "Error") with the match. Terms are highlighted with --rc-sand / --rc-on-sand. Rows matched in the path or description sort first.
- Selecting: Enter or click closes the palette, scrolls to the card (or section heading), expands the card, moves focus to the card button, and flashes the row (--rc-sand fill, 2 px --ui-primary ring, 1.5 s, then the open-card fill). With prefers-reduced-motion the flash is a static ring with no fade. The index active item updates through the existing scroll-spy. The URL hash becomes #ep-<id> so the result can be shared.
- Keys: Up and Down move (wrap), Enter opens, Esc closes, Home and End go to the first and last row. Typing resets the selection to the first row.
- Roles: input role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", aria-activedescendant; list role="listbox"; groups role="group" with aria-labelledby; rows role="option" with aria-selected and a full aria-label (method, path, description, access). A polite live region announces the count.
### Row anatomy
- Endpoint: method badge (existing colours), mono path 13/500, one-line description (ellipsis), access chip. Session-only: dashed muted badge, 400 weight path, dashed chip with lock icon, text --ui-text-muted.
- Section: icon in the badge column (hash, or lock for Session only), title 500, muted description.
- Selected: --ui-bg-elevated with a 2 px --ui-primary bar on the left (same as the section index active item). Hover: --ui-bg-elevated.
- Mobile rows: 56 px min height, badge and path on line 1, chip then description on line 2.
### Copy per state
| # | State | Copy |
|---|---|---|
| 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" |
| 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" |
| 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "6 routes"); count and live "7 results" |
| 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "6 more endpoints. Keep typing to narrow the list."; count "13 results"; live "13 results, showing 7" |
| 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" |
| 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "12 routes, not available to API keys"; "2 results" |
| 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" |
| 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" |
### Nuxt UI v4 mapping
- Trigger: UButton variant="outline" color="neutral" with a UIcon and UKbd (desktop); UButton icon-only (mobile). Shortcut: defineShortcuts({ '/': open, meta_k: open, ctrl_k: open }).
- Palette: UModal (fullscreen on mobile via the fullscreen prop) wrapping UCommandPalette with :groups, v-model:search-term, placeholder, :close (mobile "Cancel"). Fuse options off for the endpoint group (our own multi-field match), highlight via the #item-label and #item-description slots. UCommandPalette provides the combobox and listbox roles, aria-activedescendant and arrow-key handling.
- Rows: custom #item slot with UBadge (method, access chip), mono path, UIcon (lock, hash). Footer: #footer slot with UKbd for ↑ ↓ ↵ Esc. Empty: #empty slot. Live region: a visually hidden element with aria-live="polite".
- Flash: a CSS class on the EndpointCard row driven by a ref; uses var(--rc-sand) and var(--ui-primary) only.
### Assumptions to confirm
- Search is client-side over a static index built from the same data as the cards (method, path, first sentence of the description, access, request fields, error codes and messages). It does not search code samples, prose or response examples.
- Matching is substring per term, not fuzzy ("mdia" does not match "media").
- The palette shows the first sentence of each endpoint description, not the full text.
- Endpoints are capped at 6 rows with a count line; sections are never capped.
- Recent searches are left out. Open question: store the last 3 queries in localStorage?
- "/" is ignored while a text field has focus. Cmd+K on this route does not clash with any back-office shortcut (to confirm).
- The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index?
- Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed.
- The 1.5 s flash duration and the #ep-<id> hash are proposals.
+2 -1
View File
@@ -372,6 +372,7 @@
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkiLine truncated
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
<sc-if value="{{ integSearch }}"><dc-import name="IntegrationSearch" s="{{ s.search }}" mobile="{{ mob }}" hint-size="100%,100%"></dc-import></sc-if>
{{ slotOverlays }}
<sc-if value="{{ hasToasts }}">
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
@@ -546,7 +547,7 @@ class Component extends DCLogic {
contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu',
navDrawer: m && !!s.drawer, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
navDrawer: m && !!s.drawer, integSearch: k === 'integration' && !!s.search, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
pad: m ? '16px 16px 88px' : '24px', gap: '16px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
+10 -6
View File
@@ -9,8 +9,9 @@
<x-dc>
<div ref="{{ rootRef }}" onScroll="{{ onScroll }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">
<sc-if value="{{ mob }}">
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;flex-direction:column;gap:4px">
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;align-items:center;gap:8px">
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;flex:1;min-width:0;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
<button aria-label="Search the API reference" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;flex-shrink:0;width:44px;height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span></button>
<sc-if value="{{ jumpOpen }}"><div role="listbox" aria-label="Sections" style="position:absolute;left:16px;right:16px;top:56px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px">
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button role="option" aria-selected="{{ i.on }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;height:44px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;font-size:14px;background:{{ i.bg }};color:var(--ui-text-highlighted);font-weight:{{ i.fw }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ i.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></button></sc-for>
</div></sc-if>
@@ -19,11 +20,14 @@
<div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start">
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:{{ secGap }}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
<div style="display:flex;flex-direction:column;gap:6px">
<span style="color:var(--ui-text-muted)">Integration</span>
<span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API reference</span>
<span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
</div>
<sc-if value="{{ desk }}"><button aria-label="Search the API reference" aria-keyshortcuts="/ Control+K Meta+K" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;width:320px;flex-shrink:0;height:36px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);outline:{{ trigRing }};outline-offset:2px" style-hover="border-color:var(--ui-text-muted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:14px">Search the API reference…</span><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">/</span></button></sc-if>
</div>
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.</p>
<div style="display:flex;flex-direction:column;gap:6px">
@@ -60,7 +64,7 @@
<div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)">
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }};outline:{{ ep.ring }};outline-offset:-2px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
<span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;height:22px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:{{ ep.mBg }};color:{{ ep.mFg }};border:1px solid {{ ep.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ ep.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ ep.p }}</span><sc-if value="{{ mob }}"><span style="flex:1"></span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if></span>
<span style="min-width:0;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:{{ descWrap }}">{{ ep.d }}</span>
<span style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start"><sc-for list="{{ ep.chips }}" as="c" hint-placeholder-count="2"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ c.bg }};color:{{ c.fg }};border:1px {{ c.bs }} {{ c.bd }};font-size:12px;font-weight:{{ c.fw }}"><sc-if value="{{ c.icon }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ c.t }}</span></sc-for></span>
@@ -130,7 +134,7 @@
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl' } })()
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl', flash: i.flash || null } })()
rootRef = React.createRef()
BASE = 'https://cms.renew.example'
KEY = 'rcms_Kf3x9QwL0aBc…'
@@ -234,7 +238,7 @@ class Component extends DCLogic {
const cp = this.state.copied === e.id
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips,
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: this.state.flash === e.id ? 'var(--rc-sand)' : open ? 'var(--ui-bg-muted)' : 'transparent', ring: this.state.flash === e.id ? '2px solid var(--ui-primary)' : 'none', label: e.m + ' ' + e.p + '. ' + e.d, chips,
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
@@ -256,7 +260,7 @@ class Component extends DCLogic {
const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
const D = (a) => 'rowDisplay'
return {
rootRef: this.rootRef, onScroll: this.onScroll, imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
rootRef: this.rootRef, onScroll: this.onScroll, trigRing: ((this.props.s && this.props.s.int) || {}).trig === 'focus' ? '2px solid var(--ui-primary)' : 'none', imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index,
pad: mob ? '16px 16px 96px' : '24px', secGap: mob ? '28px' : '32px', h1: '20px/28px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;justify-content:center;align-items:flex-start;padding:{{ shellPad }};font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;color:var(--ui-text)">
<div role="dialog" aria-modal="true" aria-label="Search the API reference" style="width:{{ w }};height:{{ h }};max-height:100%;background:var(--ui-bg);border:{{ bd }};box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;min-height:0">
<div style="display:flex;align-items:center;gap:10px;height:{{ inH }};padding:0 {{ inPx }};border-bottom:1px solid var(--ui-border);box-shadow:inset 0 0 0 2px var(--ui-primary);flex-shrink:0">
<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span>
<input role="combobox" aria-expanded="true" aria-controls="rc-sr-list" aria-autocomplete="list" aria-activedescendant="{{ activeId }}" aria-label="Search the API reference" placeholder="Search the API reference…" value="{{ q }}" onChange="{{ onInput }}" onKeyDown="{{ onKey }}" autocomplete="off" spellcheck="false" style="all:unset;box-sizing:border-box;flex:1;min-width:0;height:100%;font:400 {{ inFs }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)"/>
<sc-if value="{{ desk }}"><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">Esc</span></sc-if>
<sc-if value="{{ mob }}"><button aria-label="Close search" onClick="{{ onClose }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 8px;margin-right:-8px;display:inline-flex;align-items:center;font-weight:500;font-size:14px;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">Cancel</button></sc-if>
</div>
<span role="status" aria-live="polite" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">{{ live }}</span>
<div id="rc-sr-list" role="listbox" aria-label="Search results" style="flex:1;min-height:0;max-height:{{ listMax }};overflow-y:auto;padding:4px 0;scrollbar-width:thin">
<sc-if value="{{ none }}"><div style="padding:40px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span aria-hidden="true" style="display:inline-block;width:20px;height:20px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search-x.svg') center / contain no-repeat"></span><span style="max-width:360px;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ noneText }}</span></div></sc-if>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="2">
<div role="group" aria-labelledby="{{ g.hid }}">
<div id="{{ g.hid }}" style="padding:8px {{ rowPx }} 4px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">{{ g.title }}</div>
<sc-for list="{{ g.items }}" as="it" hint-placeholder-count="4">
<div id="{{ it.id }}" role="option" aria-selected="{{ it.sel }}" aria-label="{{ it.label }}" onClick="{{ it.go }}" style="cursor:pointer;display:grid;grid-template-columns:{{ cols }};align-items:start;gap:{{ colGap }};padding:{{ rowPad }};min-height:{{ rowH }};background:{{ it.bg }};box-shadow:{{ it.bar }}" style-hover="background:var(--ui-bg-elevated)">
<sc-if value="{{ it.isEp }}"><span style="width:56px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:{{ it.mBg }};color:{{ it.mFg }};border:1px {{ it.mBs }} {{ it.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ it.m }}</span></sc-if>
<sc-if value="{{ it.isSec }}"><span style="width:56px;height:22px;display:inline-flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat"></span></span></sc-if>
<div style="min-width:0;display:flex;flex-direction:column;gap:2px">
<sc-if value="{{ it.isEp }}"><div style="display:flex;align-items:center;gap:12px;min-height:22px;min-width:0"><span style="flex-shrink:{{ pathShrink }};min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:{{ it.pFw }} 13px 'IBM Plex Mono',monospace;color:{{ it.pFg }}"><sc-for list="{{ it.pSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span><sc-if value="{{ desk }}"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ it.dFg }}"><sc-for list="{{ it.dSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span></sc-if></div></sc-if>
<sc-if value="{{ it.isSec }}"><div style="display:flex;align-items:center;gap:12px;min-height:22px;min-width:0"><span style="flex-shrink:0;font-weight:500;color:var(--ui-text-highlighted)"><sc-for list="{{ it.pSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span><sc-if value="{{ desk }}"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-muted)">{{ it.desc }}</span></sc-if></div></sc-if>
<sc-if value="{{ mob }}"><div style="display:flex;align-items:center;gap:8px;min-width:0"><sc-if value="{{ it.hasChip }}"><span style="flex-shrink:0;height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ it.cBg }};color:{{ it.cFg }};border:1px {{ it.cBs }} {{ it.cBd }};font-size:12px;font-weight:{{ it.cFw }}"><sc-if value="{{ it.sess }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></sc-if>{{ it.chip }}</span></sc-if><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ it.dFg }}"><sc-if value="{{ it.isEp }}"><sc-for list="{{ it.dSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></sc-if><sc-if value="{{ it.isSec }}">{{ it.desc }}</sc-if></span></div></sc-if>
<sc-if value="{{ it.via }}"><div style="display:flex;align-items:baseline;gap:8px;min-width:0;font-size:12px;line-height:18px;color:var(--ui-text-muted)"><span style="flex-shrink:0">{{ it.viaLabel }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><sc-for list="{{ it.viaSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span></div></sc-if>
</div>
<sc-if value="{{ desk }}"><sc-if value="{{ it.hasChip }}"><span style="height:20px;margin-top:1px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ it.cBg }};color:{{ it.cFg }};border:1px {{ it.cBs }} {{ it.cBd }};font-size:12px;font-weight:{{ it.cFw }}"><sc-if value="{{ it.sess }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></sc-if>{{ it.chip }}</span></sc-if></sc-if>
</div>
</sc-for>
</div>
</sc-for>
<sc-if value="{{ more }}"><div style="padding:8px {{ rowPx }};color:var(--ui-text-muted)">{{ moreText }}</div></sc-if>
</div>
<div style="flex-shrink:0;display:flex;align-items:center;gap:16px;height:{{ ftH }};padding:0 {{ inPx }};border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);color:var(--ui-text-muted)">
<sc-if value="{{ desk }}"><span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↑</span><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↓</span>Move</span>
<span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↵</span>Open</span>
<span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">Esc</span>Close</span></sc-if>
<span style="flex:1"></span><span aria-hidden="true">{{ count }}</span>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900}}">
class Component extends DCLogic {
state = { q: (this.props.s && this.props.s.q) || '', sel: (this.props.s && this.props.s.sel) || 0 }
L = 'You must be logged in to access this resource.'
V = 'You must verify your account to access this resource.'
MM = 'The request cannot be processed due to a mismatch in the format of the body.'
data() {
const L = this.L, V = this.V, MM = this.MM, SS = 'This action needs a real session.'
const E = (id, m, p, d, acc, f, e, x = {}) => ({ id, m, p, d, acc, f, e: acc === 'Verified user' ? e.flatMap(r => r[0] === 401 ? [r, [403, V]] : [r]) : e, ...x })
const S = (id, m, p, d) => ({ id, m, p, d, acc: 'Session only', f: [], e: [[403, SS]], sess: 1 })
const a404 = [404, 'Article not found'], i404 = [404, 'Image not found']
const eps = [
E('art-list', 'GET', '/v1/article/', 'List articles, one page at a time.', 'Public', ['page', 'size'], [[400, 'Invalid page or size.'], [401, L]], { qp: 1 }),
E('art-get', 'GET', '/v1/article/{id}', 'Get one article.', 'Public', [], [[401, L], a404]),
E('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', ['title', 'body'], [[400, MM], [401, L]]),
E('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', ['body', 'slug', 'excerpt', 'seo_title', 'seo_description', 'cover_image_id', 'cover_image_alt', 'social_title', 'social_description', 'social_image_id', 'canonical_url', 'noindex', 'seo_dismissed'], [[400, MM], [401, L], a404, [409, 'slug_taken']]),
E('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', [], [[401, L], a404]),
E('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', [], [[401, L], a404]),
E('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', [], [[401, L], a404]),
E('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article.', 'Public', [], [[401, L], a404]),
E('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site.', 'Public', ['page', 'size'], [[400, MM], [401, L], [404, 'site_not_found']], { qp: 1 }),
E('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', [], [[401, L]]),
E('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', ['file'], [[400, MM], [401, L], [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']]),
E('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', ['filename'], [[400, MM], [401, L], i404]),
E('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image.', 'Verified user', [], [[401, L], i404]),
E('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', ['keep_id', 'merge_ids'], [[400, MM], [401, L], [409, 'Some of the images to merge changed. Refresh and try again.']]),
E('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', [], [[401, L], i404]),
E('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', [], [[401, L]]),
E('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', ['display_name'], [[400, MM], [401, L]]),
E('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', ['email'], [[400, MM], [401, L], [409, 'This address already has an account.']]),
E('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', [], [[401, L], [404, 'Invite not found']]),
E('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', [], [[401, L], [404, 'Invite not found']]),
E('auth-me', 'GET', '/v1/auth/me', 'Get the current user.', 'Verified user', [], [[401, L]]),
E('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', [], [[401, L]]),
S('s-login', 'POST', '/v1/auth/login', 'Log in'), S('s-logout', 'POST', '/v1/auth/logout', 'Log out'), S('s-reg', 'POST', '/v1/auth/register', 'Register'), S('s-verify', 'POST', '/v1/auth/verify', 'Verify email'), S('s-reset', 'POST', '/v1/auth/password-reset', 'Reset password'),
S('s-keys', 'GET', '/v1/api-key', 'List keys'), S('s-kcreate', 'POST', '/v1/api-key', 'Create a key'), S('s-krev', 'DELETE', '/v1/api-key/{id}', 'Revoke a key'),
S('s-upd', 'GET', '/v1/system/update', 'Update status'), S('s-chk', 'POST', '/v1/system/update/check', 'Check for update'), S('s-dl', 'POST', '/v1/system/update/download', 'Download update'), S('s-rst', 'POST', '/v1/system/update/restart', 'Restart')
]
const secs = [
['auth', 'Authentication', 'Public access and API keys', 'bearer api key rcms rate limit 429 base url public hash'],
['articles', 'Articles', '7 routes', ''], ['seo', 'Search and sharing', '2 routes', 'seo sitemap'], ['media', 'Media', '6 routes', ''],
['images', 'Images and variants', 'The ?v= parameter', 'variant image width height format quality'],
['users', 'Users and invites', '6 routes', ''], ['settings', 'Settings', '1 route', ''],
['session', 'Session only', '12 routes, not available to API keys', 'login log in register password real session'],
['errors', 'Errors', 'Status codes and messages', '400 401 403 404 429 500 status error plain text'],
['notes', 'Notes', 'Images, CORS and ?format=', 'cors origins format markdown html json body_format']
]
return { eps, secs }
}
hl(t, toks) {
if (!toks.length) return [{ t, bg: 'transparent', fg: 'inherit' }]
const re = new RegExp('(' + toks.map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'ig')
return t.split(re).filter(x => x !== '').map(x => { const on = toks.some(k => k === x.toLowerCase()); return { t: x, bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'inherit' } })
}
compute() {
const { eps, secs } = this.data(), q = this.state.q.trim().toLowerCase(), toks = q ? q.split(/\s+/) : []
const byId = id => eps.find(e => e.id === id), bySec = k => secs.find(s => s[0] === k)
let ep = [], sc = [], title = 'Endpoints'
if (!toks.length) { ep = ['art-list', 'med-up', 'usr-me'].map(byId).map(e => ({ e })); sc = ['auth', 'errors'].map(bySec); title = null }
else {
eps.forEach(e => {
const hayP = (e.m + ' ' + e.p + ' ' + e.d + ' ' + e.acc).toLowerCase()
let via = null, ok = true
toks.forEach(t => {
if (hayP.includes(t)) return
const f = e.f.find(x => x.toLowerCase().includes(t))
if (f) { via = via || { label: e.qp ? 'Query parameter' : 'Body field', text: f }; return }
const r = e.e.find(([c, m]) => (c + ' ' + m).toLowerCase().includes(t))
if (r) { via = via || { label: 'Error', text: r[0] + ' ' + r[1] }; return }
ok = false
})
if (ok) ep.push({ e, via })
})
ep.sort((a, b) => (a.via ? 1 : 0) - (b.via ? 1 : 0))
sc = secs.filter(s => toks.every(t => (s[1] + ' ' + s[2] + ' ' + s[3]).toLowerCase().includes(t)))
}
return { ep, sc, toks, title }
}
renderVals() {
const mob = !!this.props.mobile, st = this.state, { ep, sc, toks, title } = this.compute()
const CAP = 6, shown = ep.slice(0, CAP), total = ep.length + sc.length
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const sel = total ? Math.min(st.sel, shown.length + sc.length - 1) : 0
let idx = 0
const mkItem = (o) => o
const epItems = shown.map(({ e, via }) => { const s = !!e.sess, mc = MB[e.m]; return mkItem({
isEp: true, isSec: false, sess: s, m: e.m, mBg: s ? 'transparent' : mc[0], mFg: s ? 'var(--ui-text-muted)' : mc[1], mBd: s ? 'var(--ui-border-accented)' : mc[2], mBs: s ? 'dashed' : 'solid',
pSeg: this.hl(e.p, toks), pFw: s ? 400 : 500, pFg: s ? 'var(--ui-text-toned)' : 'var(--ui-text-highlighted)', dSeg: this.hl(e.d, toks), dFg: s ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)',
hasChip: true, chip: e.acc, cBg: 'transparent', cFg: s ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', cBd: 'var(--ui-border-accented)', cBs: s ? 'dashed' : 'solid', cFw: 400,
via: !!via, viaLabel: via ? via.label : '', viaSeg: via ? this.hl(via.text, toks) : [], label: e.m + ' ' + e.p + '. ' + e.d + ' ' + e.acc })
})
const secItems = sc.map(s => mkItem({ isEp: false, isSec: true, icon: s[0] === 'session' ? 'lock' : 'hash', pSeg: this.hl(s[1], toks), desc: s[2], hasChip: false, via: false, dSeg: [], dFg: 'var(--ui-text-muted)', label: 'Section ' + s[1] + '. ' + s[2] }))
const groups = []
if (epItems.length) groups.push({ hid: 'rc-sr-g0', title: title || 'Suggested', items: epItems })
if (secItems.length) groups.push({ hid: 'rc-sr-g1', title: title ? 'Sections' : 'Suggested', items: secItems })
if (!title) { const all = [...epItems, ...secItems]; const ord = [secItems[0], epItems[1], secItems[1], epItems[0], epItems[2]]; groups.length = 0; groups.push({ hid: 'rc-sr-g0', title: 'Suggested', items: ord }) }
const none = !!toks.length && total === 0
const more = ep.length > CAP
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
const live = none ? 'No results' : !toks.length ? '5 suggestions' : plural(total, 'result') + (more ? ', showing ' + (shown.length + sc.length) : '')
const activeId = (!none && total) ? 'rc-sr-' + sel : undefined
const flat = groups.flatMap(g => g.items)
flat.forEach((it, i) => { const on = i === sel; Object.assign(it, { id: 'rc-sr-' + i, sel: on, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', bar: on ? 'inset 2px 0 0 var(--ui-primary)' : 'none', go: () => this.setState({ sel: i }) }) })
return {
groups, none, noneText: 'No match for "' + this.state.q.trim() + '". Try a path, a method or a status code like 404.', more, moreText: (ep.length - CAP) + ' more endpoints. Keep typing to narrow the list.',
live, count: none ? '0 results' : !toks.length ? '5 suggestions' : plural(total, 'result'), activeId, q: st.q,
desk: !mob, mob, w: mob ? '100%' : '720px', h: mob ? '100%' : 'auto', bd: mob ? '0' : '1px solid var(--ui-border)', shellPad: mob ? '0' : '96px 24px 24px',
inH: mob ? '56px' : '52px', inPx: mob ? '16px' : '16px', inFs: mob ? '16px' : '15px', ftH: mob ? '40px' : '40px', listMax: mob ? 'none' : '440px', rowPx: mob ? '16px' : '16px',
cols: mob ? '56px minmax(0,1fr)' : '56px minmax(0,1fr) auto', colGap: '12px', rowPad: mob ? '10px 16px' : '8px 16px', rowH: mob ? '56px' : '40px', pathShrink: mob ? 1 : 0,
onInput: e => this.setState({ q: e.target.value, sel: 0 }),
onClose: () => this.props.onClose && this.props.onClose(),
onKey: e => {
const n = flat.length
if (e.key === 'ArrowDown') { e.preventDefault(); this.setState({ sel: n ? (sel + 1) % n : 0 }) }
else if (e.key === 'ArrowUp') { e.preventDefault(); this.setState({ sel: n ? (sel - 1 + n) % n : 0 }) }
else if (e.key === 'Enter' && n) { e.preventDefault(); this.props.onOpen && this.props.onOpen(flat[sel].id) }
else if (e.key === 'Escape') { this.props.onClose && this.props.onClose() }
}
}
}
}
</script>
</body>
</html>
@@ -364,7 +364,17 @@ class Component extends DCLogic {
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows. The 403 row carries a muted note line (12/18, --ui-text-muted, aligned with the cause column, 2 px row gap above it): \"Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.\" The 404 and 429 rows and everything below move down by the height of the wrapped note: two 18 px lines on desktop, three on mobile, plus the 2 px gap.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} }),
...((B, X) => [
X({ name: "8a. Search: trigger focused", trigger: "Closed. Tab or the \"/\" key puts focus on the search field-style button in the page header (2 px --ui-primary ring, 2 px offset). On mobile the icon button sits right of \"Jump to\".", int: { trig: 'focus' } }, { int: {} }),
X({ name: "8b. Search: open, empty", trigger: "Ctrl or Cmd+K, \"/\" or a click. Focus is in the input. Five suggested items, no query. Footer count: \"5 suggestions\".", search: { q: '', sel: 0 } }),
X({ name: "8c. Search: typing, results", trigger: "\"media\". Endpoints first, then Sections. Matched characters use --rc-sand with --rc-on-sand. First result is selected. Announced: \"7 results\".", search: { q: 'media', sel: 0 } }),
X({ name: "8d. Search: keyboard-selected, status code", trigger: "\"404\" matches error codes. Down arrow moved the selection to the third row (--ui-bg-elevated plus a 2 px --ui-primary bar). Rows matched outside the path show the matching error under the description. Only 6 endpoints are listed; the rest are counted. Announced: \"13 results, showing 7\".", search: { q: '404', sel: 2 } }),
X({ name: "8e. Search: body field match", trigger: "\"keep_id\" is a request body field of POST /v1/media/merge. The row adds a \"Body field\" line with the match highlighted. Announced: \"1 result\".", search: { q: 'keep_id', sel: 0 } }),
X({ name: "8f. Search: session-only results", trigger: "\"login\". Session-only routes keep the card look: muted text, dashed method badge, dashed \"Session only\" chip with the lock icon. Announced: \"2 results\".", search: { q: 'login', sel: 0 } }),
X({ name: "8g. Search: no results", trigger: "\"xyz\". No option is selectable. Announced: \"No results\". Footer count: \"0 results\".", search: { q: 'xyz', sel: 0 } }),
X({ name: "8h. Search: after selecting an endpoint", trigger: "Enter on POST /v1/media/merge. The palette closes, the page scrolls to the card, the card expands and takes focus, and the row shows a highlight (--rc-sand fill, 2 px --ui-primary ring) for 1.5 s, then fades to the open-card fill. The section index (desktop) and \"Jump to\" (mobile) now read Media.", int: { scroll: 'ep-med-merge', open: 'med-merge', tab: 'fetch', flash: 'med-merge' } })
])({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration' }, (st, mo) => withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', int: {}, ...st }, mo || {}))
] },
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
Act('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
+248
View File
@@ -0,0 +1,248 @@
Feature: Integration page search, a jump-to command palette on /integration
As a developer reading the API reference
I want to find an endpoint, a body field or an error and jump to it
So that I do not have to scroll or use the browser's find-in-page
Background:
Given the SPA route /integration of the verified back-office (static page, no /v1 call)
And search is client-side only, over a static index built from API_GROUPS, SESSION_GROUPS,
PUBLIC_AUTH_ROWS, ERROR_ROWS and the page sections, with these fields per endpoint:
| field | indexed text |
| method | GET, POST, PATCH, DELETE |
| path | for example /v1/media/merge |
| description | the first sentence of the endpoint description |
| access chip | Public, Verified user, Owner or admin, Owner only, Session only |
| request field | body field names and query parameter names |
| error | each error code and message of the endpoint |
And code samples, prose and response examples are never searched
And a term matches as a case-insensitive substring; "mdia" does not match "media"
And a query is split on spaces and every term must match somewhere in the same endpoint
And there are no recent searches: an empty query shows only the "Suggested" group
And the search never hides, filters or reorders the page; find-in-page keeps working
And the components are Nuxt UI v4 (UModal with a custom listbox, UInput, UButton, UKbd, UBadge,
UIcon) and not UCommandPalette, with design tokens only
And every count and sample row below follows the real catalogue, not the board's sample data
And the palette is the board of docs/design/screens/integration.md "Search (jump-to palette)",
states 8a to 8h, in light and dark, at 1440x900 and 390x844
And no state is date dependent
Rule: Trigger and shortcuts
Scenario: Desktop trigger
Given the viewport is 1440x900
Then the page header holds a field-style outline neutral button, 320x36, right of the title block
And it shows the icon, the placeholder "Search the API reference…" and the key hint "/"
And its aria-label is "Search the API reference"
And it has aria-keyshortcuts "/ Control+K Meta+K"
And it is not sticky: it scrolls away with the header
Scenario: Mobile trigger
Given the viewport is 390x844
Then the sticky "Jump to" bar holds a 44x44 icon-only button right of the select
And its aria-label is "Search the API reference"
And the desktop trigger is not displayed
Scenario Outline: Opening the palette with a shortcut
Given focus is on <focus>
When the user presses <keys>
Then the palette <result>
Examples:
| focus | keys | result |
| the page body | / | opens and the "/" is not typed |
| a button | / | opens |
| the page body | Ctrl+K | opens |
| the page body | Cmd+K | opens |
| a text input of the page | / | does not open and "/" is typed |
| a textarea | / | does not open |
| a text input | Ctrl+K | opens |
Scenario: Ctrl+K and Cmd+K replace the browser behaviour on this page
When the palette opens with Ctrl+K or Cmd+K
Then the default browser action is prevented
Scenario: Shortcuts only exist on /integration
When the user leaves /integration
Then "/", Ctrl+K and Cmd+K no longer open a palette
And Ctrl/Cmd+S on the editor route is unchanged
Scenario: Opening focuses the input and closing restores focus
Given the user opened the palette from the desktop trigger
Then focus is in the input
When the user closes the palette
Then focus returns to the trigger
And when it was opened with "/" or Ctrl+K, focus returns to the element that had it
Rule: Palette layout
Scenario: Desktop modal
Given the viewport is 1440x900
When the palette opens
Then it is a modal 720 px wide, 96 px from the top, on the existing overlay
And the input has placeholder "Search the API reference…"
And the footer reads "↑↓ Move", "↵ Open", "Esc Close" and the count
Scenario: Mobile full-screen sheet
Given the viewport is 390x844
When the palette opens
Then it is a full-screen sheet
And the input row holds a 44 px "Cancel" button
And pressing "Cancel" closes the palette and returns focus to the trigger
Rule: Content per state
Scenario: 8b Open, empty query
When the palette opens with an empty query
Then one group "Suggested" shows these 5 rows in order:
| row | description | access |
| Authentication | Public access and API keys | section |
| POST /v1/media/ | Upload an image (multipart). | Verified user |
| Errors | Status codes and messages | section |
| GET /v1/article/ | List one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). | Public |
| PATCH /v1/user/me | Update my profile. | Verified user |
And the first row is selected
And the footer count and the live region read "5 suggestions"
Scenario: 8c Typing "media"
When the user types "media"
Then group "Endpoints" lists the first 6 of 11 media routes
And group "Sections" lists "Media" with description "11 routes"
And the line "5 more endpoints. Keep typing to narrow the list." follows the rows
And the count reads "12 results"
And the live region reads "12 results, showing 7"
And "media" is highlighted with --rc-sand on --rc-on-sand in paths and descriptions
Scenario: 8d More endpoints than the cap
When the user types "404"
Then group "Endpoints" lists 6 rows, each with a second line "Error" and the match, for example "404 Article not found"
And group "Sections" lists "Errors" with description "Status codes and messages"
And the line "8 more endpoints. Keep typing to narrow the list." follows the endpoint rows
And the count reads "15 results"
And the live region reads "15 results, showing 7"
Scenario: 8e Match on a body field
When the user types "keep_id"
Then group "Endpoints" lists exactly POST /v1/media/merge
And its description is "Merge identical copies into one image." and its chip is "Verified user"
And a second line reads "Body field keep_id"
And the count reads "1 result"
Scenario: 8f Session-only route
When the user types "login"
Then group "Endpoints" lists POST /v1/auth/login, "Log in", with a dashed "Session only" chip and a lock icon
And its path weight is 400 and its text is --ui-text-muted
And group "Sections" lists "Session only" with a lock icon and "25 routes, not available to API keys"
And the count reads "2 results"
Scenario: 8g No results
When the user types "xyz"
Then the empty slot reads "No match for "xyz". Try a path, a method or a status code like 404."
And the count reads "0 results"
And the live region reads "No results"
Rule: Matching
Scenario Outline: Multi-term and field matching
When the user types "<query>"
Then the endpoint rows are those whose indexed text contains every term
And the second line is "<second line>"
Examples:
| query | example hit | second line |
| post media | POST /v1/media/ | none |
| PATCH user | PATCH /v1/user/me | none |
| keep_id | POST /v1/media/merge | Body field keep_id |
| page | GET /v1/site/{key}/sitemap | Query parameter page |
| 404 | several routes | Error 404 … |
| OWNER ONLY | owner-only routes | none |
| mdia | none | 0 results |
Scenario: Ordering
Given a route matches "token" only in a body field and another matches in its path
Then the path match is listed first
And routes that tie keep the order of the catalogue
Scenario: A row shows the second line only when matched in a field or error
Then a row matched in method, path, description or chip has no second line
Scenario: Highlighting
Then every term occurrence in the label and description is wrapped in a highlight
And terms inside the second line are highlighted too
Rule: Keyboard navigation
Scenario Outline: Moving the selection
Given the palette shows 7 rows and row <from> is selected
When the user presses <key>
Then row <to> is selected and the input keeps focus
Examples:
| from | key | to |
| 1 | ArrowDown | 2 |
| 7 | ArrowDown | 1 |
| 1 | ArrowUp | 7 |
| 3 | Home | 1 |
| 3 | End | 7 |
Scenario: Typing resets the selection
Given row 3 is selected
When the user types another character
Then row 1 is selected
Scenario: Enter opens and Escape closes
When the user presses Enter on a selected row
Then that row is selected as in the "Selecting a result" rule
When the user presses Esc
Then the palette closes, nothing scrolls and the hash is unchanged
Rule: Accessibility
Scenario: Roles and live region
Then the input has role "combobox", aria-expanded, aria-controls, aria-autocomplete "list"
and aria-activedescendant pointing at the selected row
And the list has role "listbox"; each group has role "group" and aria-labelledby its title
And each row has role "option", aria-selected and an aria-label with method, path, description and access
And a visually hidden polite live region announces the count of the current state
Rule: Selecting a result
Scenario: 8h Selecting an endpoint
Given the palette shows POST /v1/media/merge
When the user presses Enter, or clicks the row
Then the palette closes
And the page scrolls to the card inside its own scroll container, below the sticky offset
And the card is expanded
And focus is on the card's button
And the row flashes: --rc-sand fill and a 2 px --ui-primary ring for 1.5 s, then the open-card fill
And the section index and "Jump to" read "Media" (existing scroll-spy)
And the URL hash is #ep-<id>, written with history.replaceState so no history entry is added
And the other cards keep their open or closed state
Scenario: Selecting a section
When the user selects the "Errors" row
Then the palette closes, the page scrolls to the Errors heading, focus is on the heading,
the hash is #errors and nothing is flashed or expanded
Scenario: Selecting a session-only route
When the user selects POST /v1/auth/login
Then the page scrolls to the "Session only" section and focus goes to its heading
And no card expands
Scenario: Reduced motion
Given the user prefers reduced motion
When a card is selected
Then the flash is a static --rc-sand fill and ring with no fade
Scenario: Shared link
When /integration#ep-<id> is loaded
Then the card is scrolled to, expanded, focused and flashed as above
And the palette stays closed
Scenario: Unknown hash
When /integration#ep-nope is loaded
Then the page behaves as today, nothing is expanded and no error shows
Scenario: The page is never filtered
Given a query is typed
Then every section and card of the page stays in the DOM, unchanged
+58
View File
@@ -0,0 +1,58 @@
# QA procedure: Integration page search (jump-to palette)
No Go test: SPA only, no /v1 call, no API change. Counts and sample rows follow the real catalogue, not the board's sample data. The search index is built from the in-repo catalogue, so tests use the real `API_GROUPS`, `SESSION_GROUPS`, `PUBLIC_AUTH_ROWS` and `ERROR_ROWS`.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Suggested files: `web/src/views/__tests__/IntegrationSearch.spec.ts` (palette), `web/src/lib/__tests__/apiSearch.spec.ts` (pure matcher), plus additions to `IntegrationView.spec.ts` for the trigger and the hash.
Setup:
- Mount IntegrationView like the existing spec (router at `/integration`, attached to the document body for focus checks).
- No `/v1` mocks: assert no API call is made.
- Stub `scrollTo`, `scrollIntoView` and `getBoundingClientRect` as the existing spec does; fake timers for the 1.5 s flash class. No test freezes the system date.
- Mock `matchMedia('(prefers-reduced-motion: reduce)')` for the reduced-motion case, and the viewport for desktop vs mobile.
Cases (one `it` per scenario or Examples row; `it.each` for outlines):
1. Trigger: desktop button has placeholder, "/" kbd, aria-label, aria-keyshortcuts; mobile icon button sits beside "Jump to".
2. Shortcuts: `/`, Ctrl+K and Cmd+K open; `/` ignored in input, textarea and contenteditable and not typed into the palette input; Ctrl+K opens from inside an input and calls preventDefault; listeners removed on unmount; Ctrl/Cmd+S in the editor is not affected.
3. Open focus: input focused; close returns focus to the opener; Cancel on mobile.
4. Empty query: the 5 Suggested rows in order, first selected, "5 suggestions" in footer and live region; no recent searches stored.
5. "media": 6 endpoint rows of 11, Sections row Media "11 routes", cap line "5 more endpoints. Keep typing to narrow the list.", "12 results", live "12 results, showing 7", highlight marks.
6. "404": 6 rows with "Error" second line, Errors section, cap line "8 more endpoints. Keep typing to narrow the list.", "15 results", live "15 results, showing 7".
7. "keep_id": one row, "Body field keep_id", "1 result". "page": GET /v1/site/{key}/sitemap shows "Query parameter page"; GET /v1/article/ matches in its description and has no second line.
8. "login": dashed chip with lock, weight 400, Session only section row "25 routes, not available to API keys" with lock icon, "2 results".
9. "xyz": empty copy, "0 results", live "No results".
10. Matcher unit table: multi-term AND, case-insensitive, substring, "mdia" no match, extra spaces trimmed, path/description matches first then catalogue order, no search over code samples or response examples.
11. Keyboard: ArrowDown/ArrowUp wrap, Home, End, typing resets to row 1, Enter selects, Esc closes with no scroll and no hash change.
12. ARIA: combobox attributes, aria-activedescendant follows selection, listbox, group labelling, option aria-label, polite live region.
13. Select endpoint: palette closed, scroll helper called with the card, card expanded, focus on the card button, flash class present then removed after 1500 ms, hash `#ep-<id>` set through `history.replaceState` (history length unchanged), other cards unchanged, index at "Media".
14. Select section: heading focused, hash is the section id, no flash, no expansion.
15. Select session-only route: Session only heading focused, no card expanded.
16. Reduced motion: static ring class, no animation class.
17. Deep link: mount with `#ep-<id>` runs case 13 without opening the palette; `#ep-nope` does nothing.
18. Never filtered: after typing, the number of cards and sections in the page DOM is unchanged.
19. Regression: existing `IntegrationView.spec.ts` cases (section hash, scroll-spy, Jump to) still pass.
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
References: `docs/design/screens/integration.md` "Search (jump-to palette)", `docs/design/source/IntegrationSearch.dc.html`, states 8a to 8h of `RenewCMS Screens Back-office.dc.html`, and `AppFrame.dc.html`. Add each state to `web/visual/states.ts` with no /v1 mocks.
| state | setup | notes |
|---|---|---|
| 8a Trigger focused | focus the trigger | |
| 8b Open, empty | open the palette | 5 Suggested rows |
| 8c "media" | type "media" | 6 of 11 endpoints, Media section, 12 results |
| 8d "404", third row selected | type "404", ArrowDown x2 | cap line, 15 results |
| 8e "keep_id" | type "keep_id" | 1 result |
| 8f "login" | type "login" | session-only styling |
| 8g no results | type "xyz" | |
| 8h After selecting | select POST /v1/media/merge | capture after the flash with its animation disabled in the setup; the flash itself is not checked |
Queries and rows use the board's sample data exactly as in the table.
## Manual checks (screenshots cannot cover)
- Keyboard and focus order: Tab reaches the trigger; "/" and Ctrl/Cmd+K open from the page; focus is trapped in the modal; Esc returns focus to the trigger.
- Flash motion: the 1.5 s fade is visible, and the OS reduced-motion setting swaps it for a static ring.
- Hover on rows and the trigger.
- Real browsers: Ctrl+K / Cmd+K does not open the address-bar search in Chrome, Firefox and Safari.
- Scroll offsets under the sticky bar on mobile and desktop for several groups.
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useMediaQuery } from '@vueuse/core'
import { computed } from 'vue'
import ApiSearchPanel from '@/components/ApiSearchPanel.vue'
import type { SearchEntry, SearchIndex } from '@/lib/apiSearch'
defineProps<{
index: SearchIndex
}>()
const emit = defineEmits<{ select: [entry: SearchEntry] }>()
const DESKTOP_QUERY = '(min-width: 64rem)'
const open = defineModel<boolean>('open', { required: true })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const contentClass = computed(() =>
isDesktop.value
? 'divide-y-0 rounded-none border border-default shadow-(--shadow-modal) ring-0 sm:top-24 sm:max-w-180 sm:translate-y-0'
: 'divide-y-0 rounded-none',
)
function keepFocusWithCaller(event: Event) {
event.preventDefault()
}
function onSelect(entry: SearchEntry) {
open.value = false
emit('select', entry)
}
</script>
<template>
<UModal
v-model:open="open"
:title="t('Search the API reference')"
:description="t('Search the API reference…')"
:fullscreen="!isDesktop"
:close="false"
:content="{ onCloseAutoFocus: keepFocusWithCaller }"
:ui="{ content: contentClass }"
>
<template #content>
<ApiSearchPanel
:index="index"
:mobile="!isDesktop"
@select="onSelect"
@close="open = false"
/>
</template>
</UModal>
</template>
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import ApiSearchRow from '@/components/ApiSearchRow.vue'
import {
ENDPOINT_CAP,
queryTerms,
searchIndex,
suggestedHits,
type SearchEntry,
type SearchHit,
type SearchIndex,
} from '@/lib/apiSearch'
const props = defineProps<{
index: SearchIndex
mobile: boolean
}>()
const emit = defineEmits<{ select: [entry: SearchEntry]; close: [] }>()
interface PanelRow {
id: string
hit: SearchHit
}
interface PanelGroup {
id: string
title: string
rows: PanelRow[]
}
const LIST_ID = 'rc-sr-list'
const root = ref<HTMLElement | null>(null)
const query = ref('')
const position = ref(0)
const terms = computed(() => queryTerms(query.value))
const suggesting = computed(() => terms.value.length === 0)
const result = computed(() => searchIndex(props.index, query.value))
const shownEndpoints = computed(() => result.value.endpoints.slice(0, ENDPOINT_CAP))
const hiddenEndpoints = computed(() => result.value.endpoints.length - shownEndpoints.value.length)
const hitGroups = computed(() => {
if (suggesting.value) {
return [{ title: t('Suggested'), hits: suggestedHits(props.index) }]
}
return [
{ title: t('Endpoints'), hits: shownEndpoints.value },
{ title: t('Sections'), hits: result.value.sections },
].filter((group) => group.hits.length > 0)
})
const groups = computed<PanelGroup[]>(() => {
let next = 0
return hitGroups.value.map((group, groupIndex) => ({
id: `rc-sr-g${groupIndex}`,
title: group.title,
rows: group.hits.map((hit) => ({ id: `rc-sr-${next++}`, hit })),
}))
})
const rows = computed(() => groups.value.flatMap((group) => group.rows))
const selected = computed(() => Math.min(position.value, Math.max(rows.value.length - 1, 0)))
const activeId = computed(() => rows.value[selected.value]?.id)
const empty = computed(() => !suggesting.value && rows.value.length === 0)
const total = computed(() =>
suggesting.value
? rows.value.length
: result.value.endpoints.length + result.value.sections.length,
)
const countText = computed(() => {
if (suggesting.value) {
return t('{0} suggestions', [total.value])
}
return total.value === 1 ? t('{0} result', [1]) : t('{0} results', [total.value])
})
const liveText = computed(() => {
if (empty.value) {
return t('No results')
}
return hiddenEndpoints.value > 0
? t('{0} results, showing {1}', [total.value, rows.value.length])
: countText.value
})
const moreText = computed(() =>
hiddenEndpoints.value === 1
? t('{0} more endpoint. Keep typing to narrow the list.', [1])
: t('{0} more endpoints. Keep typing to narrow the list.', [hiddenEndpoints.value]),
)
const emptyText = computed(() =>
t('No match for "{0}". Try a path, a method or a status code like 404.', [query.value.trim()]),
)
watch(query, () => {
position.value = 0
})
watch(selected, async () => {
await nextTick()
document.getElementById(activeId.value ?? '')?.scrollIntoView?.({ block: 'nearest' })
})
onMounted(async () => {
await nextTick()
root.value?.querySelector('input')?.focus()
})
function choose(row: PanelRow) {
emit('select', row.hit.entry)
}
function move(target: number) {
const count = rows.value.length
position.value = count === 0 ? 0 : (target + count) % count
}
function onKeydown(event: KeyboardEvent) {
if (event.isComposing) {
return
}
const handlers: Record<string, () => void> = {
ArrowDown: () => move(selected.value + 1),
ArrowUp: () => move(selected.value - 1),
Home: () => move(0),
End: () => move(rows.value.length - 1),
Enter: () => {
const row = rows.value[selected.value]
if (row) {
choose(row)
}
},
}
const handler = handlers[event.key]
if (handler) {
event.preventDefault()
handler()
return
}
if (event.key === 'Escape') {
emit('close')
}
}
const KBD_CLASS =
'h-5 min-w-5 justify-center rounded-none border border-accented bg-default px-1 font-mono normal-case text-xs font-medium text-toned ring-0'
</script>
<template>
<div ref="root" class="flex h-full min-h-0 flex-col text-sm text-default">
<div
class="flex shrink-0 items-center gap-2.5 border-b border-default px-4 ring-2 ring-primary ring-inset"
:class="mobile ? 'h-14' : 'h-13'"
>
<UInput
v-model="query"
variant="none"
icon="i-lucide-search"
:placeholder="t('Search the API reference…')"
autocomplete="off"
spellcheck="false"
role="combobox"
aria-expanded="true"
:aria-controls="LIST_ID"
aria-autocomplete="list"
:aria-activedescendant="activeId"
:aria-label="t('Search the API reference')"
:ui="{
root: 'min-w-0 flex-1',
base: 'h-full px-0 ps-7 text-lg text-highlighted lg:text-base',
leading: 'ps-0',
leadingIcon: 'size-4.5 text-muted',
}"
class="h-full"
@keydown="onKeydown"
/>
<UKbd v-if="!mobile" value="Esc" :class="KBD_CLASS" />
<UButton
v-else
:label="t('Cancel')"
color="neutral"
variant="ghost"
class="-mr-2 h-11 rounded-none px-2 text-base font-medium text-highlighted"
@click="emit('close')"
/>
</div>
<span role="status" aria-live="polite" class="sr-only">{{ liveText }}</span>
<div
:id="LIST_ID"
role="listbox"
:aria-label="t('Search results')"
class="min-h-0 flex-1 overflow-y-auto py-1 lg:max-h-110"
>
<div v-if="empty" class="flex flex-col items-center gap-2.5 px-6 py-10 text-center">
<UIcon name="i-lucide-search-x" class="size-5 text-muted" />
<span class="max-w-90 text-base/5.5 text-pretty text-toned">{{ emptyText }}</span>
</div>
<div v-for="group in groups" :key="group.id" role="group" :aria-labelledby="group.id">
<div
:id="group.id"
class="px-4 pt-2 pb-1 text-xs/5 font-medium tracking-wide text-muted uppercase"
>
{{ group.title }}
</div>
<ApiSearchRow
v-for="row in group.rows"
:id="row.id"
:key="row.id"
:hit="row.hit"
:terms="terms"
:selected="row.id === activeId"
:mobile="mobile"
@choose="choose(row)"
/>
</div>
<div v-if="hiddenEndpoints > 0" class="px-4 py-2 text-muted">{{ moreText }}</div>
</div>
<div
class="flex h-10 shrink-0 items-center gap-4 border-t border-default bg-muted px-4 text-muted"
>
<template v-if="!mobile">
<span aria-hidden="true" class="inline-flex items-center gap-1.5">
<UKbd value="↑" :class="KBD_CLASS" />
<UKbd value="↓" :class="KBD_CLASS" />
{{ t('Move') }}
</span>
<span aria-hidden="true" class="inline-flex items-center gap-1.5">
<UKbd value="↵" :class="KBD_CLASS" />
{{ t('Open') }}
</span>
<span aria-hidden="true" class="inline-flex items-center gap-1.5">
<UKbd value="Esc" :class="KBD_CLASS" />
{{ t('Close') }}
</span>
</template>
<span class="flex-1" />
<span aria-hidden="true">{{ countText }}</span>
</div>
</div>
</template>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import HighlightedText from '@/components/HighlightedText.vue'
import type { SearchHit } from '@/lib/apiSearch'
import { CHIP_CLASS, METHOD_CLASS, SESSION_METHOD_CLASS } from '@/lib/apiReference'
const props = defineProps<{
id: string
hit: SearchHit
terms: string[]
selected: boolean
mobile: boolean
}>()
const emit = defineEmits<{ choose: [] }>()
const endpoint = computed(() => (props.hit.entry.kind === 'endpoint' ? props.hit.entry : null))
const section = computed(() => (props.hit.entry.kind === 'section' ? props.hit.entry : null))
const optionLabel = computed(() =>
endpoint.value
? `${endpoint.value.method} ${endpoint.value.path}. ${endpoint.value.description}. ${endpoint.value.access}`
: t('Section {0}. {1}', [section.value!.title, section.value!.description]),
)
const viaLabel = computed(() => {
const kind = props.hit.via?.kind
if (kind === 'query') {
return t('Query parameter')
}
return kind === 'body' ? t('Body field') : t('Error')
})
const descriptionClass = computed(() => (endpoint.value?.session ? 'text-muted' : 'text-toned'))
const pathClass = computed(() =>
endpoint.value?.session ? 'font-normal text-toned' : 'font-medium text-highlighted',
)
const chipClass = computed(() => (endpoint.value?.session ? CHIP_CLASS.dashed : CHIP_CLASS.neutral))
const methodClass = computed(() =>
endpoint.value?.session ? SESSION_METHOD_CLASS : METHOD_CLASS[endpoint.value!.method],
)
</script>
<template>
<div
:id="id"
role="option"
:aria-selected="selected"
:aria-label="optionLabel"
class="grid cursor-pointer items-start gap-3 px-4 hover:bg-elevated"
:class="[
mobile
? 'min-h-14 grid-cols-[3.5rem_minmax(0,1fr)] py-2.5'
: 'min-h-10 grid-cols-[3.5rem_minmax(0,1fr)_auto] py-2',
selected ? 'search-row-selected bg-elevated' : '',
]"
@click="emit('choose')"
>
<UBadge
v-if="endpoint"
:label="endpoint.method"
color="neutral"
variant="outline"
:class="methodClass"
/>
<span v-else class="flex h-5.5 w-14 items-center justify-center text-muted">
<UIcon :name="section!.locked ? 'i-lucide-lock' : 'i-lucide-hash'" class="size-4" />
</span>
<div class="flex min-w-0 flex-col gap-0.5">
<div v-if="endpoint" class="flex min-h-5.5 min-w-0 items-center gap-3">
<span
class="min-w-0 truncate font-mono text-sm"
:class="[pathClass, mobile ? '' : 'shrink-0']"
><HighlightedText :text="endpoint.path" :terms="terms"
/></span>
<span v-if="!mobile" class="min-w-0 flex-1 truncate" :class="descriptionClass"
><HighlightedText :text="endpoint.description" :terms="terms"
/></span>
</div>
<div v-else class="flex min-h-5.5 min-w-0 items-center gap-3">
<span class="shrink-0 font-medium text-highlighted"
><HighlightedText :text="section!.title" :terms="terms"
/></span>
<span v-if="!mobile" class="min-w-0 flex-1 truncate text-muted"
><HighlightedText :text="section!.description" :terms="terms"
/></span>
</div>
<div v-if="mobile" class="flex min-w-0 items-center gap-2">
<UBadge
v-if="endpoint"
:label="endpoint.access"
:icon="endpoint.session ? 'i-lucide-lock' : undefined"
color="neutral"
variant="outline"
:class="chipClass"
:ui="{ leadingIcon: 'size-3' }"
/>
<span class="min-w-0 flex-1 truncate" :class="descriptionClass"
><HighlightedText
:text="endpoint ? endpoint.description : section!.description"
:terms="terms"
/></span>
</div>
<div v-if="hit.via" class="flex min-w-0 items-baseline gap-2 text-xs/4.5 text-muted">
<span class="shrink-0">{{ viaLabel }}</span>
<span class="min-w-0 truncate font-mono text-toned"
><HighlightedText :text="hit.via.text" :terms="terms"
/></span>
</div>
</div>
<UBadge
v-if="endpoint && !mobile"
:label="endpoint.access"
:icon="endpoint.session ? 'i-lucide-lock' : undefined"
color="neutral"
variant="outline"
class="mt-px"
:class="chipClass"
:ui="{ leadingIcon: 'size-3' }"
/>
</div>
</template>
<style scoped>
.search-row-selected {
box-shadow: inset 2px 0 0 var(--ui-primary);
}
</style>
+41 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import ApiFieldTable from '@/components/ApiFieldTable.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import { RATE_LIMIT_SENTENCE, endpointExamples, type ApiEndpoint } from '@/data/apiCatalogue'
@@ -10,9 +10,19 @@ import { CHIP_CLASS, METHOD_CLASS, endpointAnchor } from '@/lib/apiReference'
const props = defineProps<{
endpoint: ApiEndpoint
origin: string
flash?: 'animated' | 'static' | null
}>()
const open = ref(false)
const rowButton = ref<HTMLButtonElement | null>(null)
async function reveal() {
open.value = true
await nextTick()
rowButton.value?.focus({ preventScroll: true })
}
defineExpose({ reveal })
const activeTab = ref('fetch')
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
@@ -42,10 +52,15 @@ const snippetTabs = computed(() => [
:ui="{ content: 'border-t border-muted' }"
>
<button
ref="rowButton"
type="button"
:aria-label="rowLabel"
class="flex min-h-11 w-full cursor-pointer flex-col items-stretch gap-1.5 px-3 py-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary lg:grid lg:grid-cols-[300px_minmax(0,1fr)_auto_16px] lg:items-center lg:gap-4 lg:px-4 lg:py-0"
:class="open ? 'bg-muted' : ''"
:class="[
open ? 'bg-muted' : '',
flash === 'animated' ? 'endpoint-flash' : '',
flash === 'static' ? 'endpoint-flash-static' : '',
]"
>
<span class="flex min-w-0 items-center gap-2.5">
<UBadge
@@ -134,3 +149,27 @@ const snippetTabs = computed(() => [
</template>
</UCollapsible>
</template>
<style scoped>
.endpoint-flash,
.endpoint-flash-static {
background: var(--rc-sand);
box-shadow: inset 0 0 0 2px var(--ui-primary);
}
.endpoint-flash {
animation: endpoint-flash-fade 1.5s ease-out forwards;
}
@keyframes endpoint-flash-fade {
0%,
60% {
background: var(--rc-sand);
box-shadow: inset 0 0 0 2px var(--ui-primary);
}
100% {
background: var(--ui-bg-muted);
box-shadow: inset 0 0 0 2px transparent;
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { highlightSegments } from '@/lib/apiSearch'
const props = defineProps<{
text: string
terms: string[]
}>()
const segments = computed(() => highlightSegments(props.text, props.terms))
</script>
<template>
<template v-for="(segment, position) in segments" :key="position">
<mark v-if="segment.marked" class="bg-(--rc-sand) text-(--rc-on-sand)">{{
segment.text
}}</mark>
<template v-else>{{ segment.text }}</template>
</template>
</template>
+17 -3
View File
@@ -13,7 +13,7 @@ const props = defineProps<{
active: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const emit = defineEmits<{ select: [id: string]; search: [trigger: HTMLElement | null] }>()
const activeLabel = computed(
() => props.sections.find((section) => section.id === props.active)?.label ?? '',
@@ -22,6 +22,10 @@ const activeLabel = computed(
function onJump(id: string) {
emit('select', id)
}
function onSearch(event: MouseEvent) {
emit('search', event.currentTarget as HTMLElement | null)
}
</script>
<template>
@@ -51,7 +55,7 @@ function onJump(id: string) {
</nav>
<div
class="sticky -top-4 z-5 order-first -mx-4 -mt-4 mb-4 border-b border-default bg-default px-4 py-2 lg:hidden"
class="sticky -top-4 z-5 order-first -mx-4 -mt-4 mb-4 flex items-center gap-2 border-b border-default bg-default px-4 py-2 lg:hidden"
>
<USelectMenu
:model-value="active"
@@ -63,7 +67,7 @@ function onJump(id: string) {
:aria-label="t('Jump to section')"
color="neutral"
variant="outline"
class="w-full"
class="min-w-0 flex-1"
:content="{ sideOffset: 4 }"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 hover:bg-default',
@@ -80,5 +84,15 @@ function onJump(id: string) {
}}</span>
</span>
</USelectMenu>
<UButton
icon="i-lucide-search"
color="neutral"
variant="outline"
:aria-label="t('Search the API reference')"
aria-haspopup="dialog"
class="size-11 shrink-0 justify-center rounded-none border border-accented bg-default text-highlighted ring-0 hover:bg-default"
:ui="{ leadingIcon: 'size-4.5' }"
@click="onSearch"
/>
</div>
</template>
+23
View File
@@ -2718,6 +2718,29 @@ export const messages = {
'How are they connected?': 'Comment sont-ils liés ?',
'Each {0} has': 'Chaque {0} a',
'shown on {0} as the field "{1}"': 'affiché sur {0} comme le champ « {1} »',
'Search the API reference': 'Rechercher dans la référence API',
'Search the API reference…': 'Rechercher dans la référence API…',
'Search results': 'Résultats de la recherche',
Endpoints: 'Routes',
Sections: 'Sections',
Move: 'Naviguer',
'{0} result': '{0} résultat',
'{0} results, showing {1}': '{0} résultats, {1} affichés',
'{0} more endpoint. Keep typing to narrow the list.':
'{0} route de plus. Continuez à écrire pour affiner la liste.',
'{0} more endpoints. Keep typing to narrow the list.':
'{0} routes de plus. Continuez à écrire pour affiner la liste.',
'No match for "{0}". Try a path, a method or a status code like 404.':
'Aucun résultat pour « {0} ». Essayez un chemin, une méthode ou un code d’état comme 404.',
'Body field': 'Champ du corps',
'Query parameter': 'Paramètre d’URL',
Error: 'Erreur',
'Section {0}. {1}': 'Section {0}. {1}',
'Public access and API keys': 'Accès public et clés API',
'The ?v= parameter': 'Le paramètre ?v=',
'Status codes and messages': 'Codes d’état et messages',
'Images, CORS and ?format=': 'Images, CORS et ?format=',
'{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API',
} as const
export type MessageKey = keyof typeof messages
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest'
import {
buildSearchIndex,
firstSentence,
highlightSegments,
queryTerms,
searchIndex,
suggestedHits,
type EndpointEntry,
type SearchHit,
} from '../apiSearch'
const index = buildSearchIndex()
function routes(hits: SearchHit[]) {
return hits.map((hit) =>
hit.entry.kind === 'endpoint'
? `${hit.entry.method} ${hit.entry.path}`
: `section ${hit.entry.title}`,
)
}
function search(query: string) {
return searchIndex(index, query)
}
describe('firstSentence', () => {
it.each([
['Get one article. Drafts answer 404 to anonymous callers.', 'Get one article.'],
['Rename an image', 'Rename an image'],
['Upload an image (multipart).', 'Upload an image (multipart).'],
])('cuts %s', (text, expected) => {
expect(firstSentence(text)).toBe(expected)
})
})
describe('queryTerms', () => {
it('splits on spaces, trims and lowercases', () => {
expect(queryTerms(' POST Media ')).toEqual(['post', 'media'])
expect(queryTerms(' ')).toEqual([])
})
})
describe('index', () => {
it('covers documented, session only and public auth routes', () => {
const keys = index.endpoints.map((entry) => entry.key)
expect(keys).toContain('ep:med-merge')
expect(keys).toContain('session:POST /v1/auth/login')
expect(keys).toContain('public:GET /healthz')
expect(index.sections.map((section) => section.sectionId)).toEqual([
'auth',
'articles',
'seo',
'media',
'users',
'settings',
'sites',
'authors',
'images',
'session',
'errors',
'notes',
])
})
it('never indexes code samples or response examples', () => {
expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([])
expect(search('"total"').endpoints).toEqual([])
expect(search('curl').endpoints).toEqual([])
})
})
describe('matching', () => {
it('matches terms case-insensitively as substrings', () => {
expect(routes(search('MEDIA').endpoints)).toContain('POST /v1/media/merge')
expect(routes(search('edia').endpoints)).toContain('POST /v1/media/merge')
})
it('does not match a misspelled term', () => {
const result = search('mdia')
expect(result.endpoints).toEqual([])
expect(result.sections).toEqual([])
})
it('requires every term in the same endpoint', () => {
const result = routes(search('post media').endpoints)
expect(result).toContain('POST /v1/media/')
expect(result.every((route) => !route.startsWith('GET'))).toBe(true)
expect(routes(search('patch user').endpoints)).toContain('PATCH /v1/user/me')
})
it('ignores extra spaces', () => {
expect(routes(search(' post media ').endpoints)).toEqual(
routes(search('post media').endpoints),
)
})
it('returns nothing for an empty query', () => {
expect(search(' ')).toEqual({ endpoints: [], sections: [] })
})
it('matches the access chip', () => {
const owners = search('OWNER ONLY').endpoints.map((hit) => hit.entry as EndpointEntry)
expect(owners.map((entry) => entry.path)).toEqual(
expect.arrayContaining(['/v1/user/invite', '/v1/user/invite/{id}']),
)
expect(
owners.every((entry) => entry.access === 'Owner only' || entry.path.length > 0),
).toBe(true)
})
it('matches a body field and reports it as the second line', () => {
const hits = search('keep_id').endpoints
expect(routes(hits)).toEqual(['POST /v1/media/merge'])
expect(hits[0]!.via).toEqual({ kind: 'body', text: 'keep_id' })
expect(search('keep_id').sections).toEqual([])
})
it('matches a query parameter and labels it', () => {
const hit = search('hash').endpoints.find(
(item) => (item.entry as EndpointEntry).path === '/v1/media/',
)
expect(hit!.via).toEqual({ kind: 'query', text: 'hash' })
})
it('matches an error code or message and reports the first error', () => {
const hits = search('404').endpoints
expect(hits.length).toBeGreaterThan(6)
const withError = hits.find((hit) => hit.via?.kind === 'error')!
expect(withError.via!.text.startsWith('404 ')).toBe(true)
expect(routes(search('404').sections)).toEqual(['section Errors'])
})
it('shows no second line for a path, description, method or chip match', () => {
for (const query of ['merge', 'identical copies', 'patch', 'verified user']) {
expect(search(query).endpoints.some((hit) => hit.via === null)).toBe(true)
}
expect(search('merge').endpoints[0]!.via).toBeNull()
})
it('lists path and description matches first, then catalogue order', () => {
const hits = search('token').endpoints
const firstIndirect = hits.findIndex((hit) => hit.via !== null)
if (firstIndirect !== -1) {
expect(hits.slice(0, firstIndirect).every((hit) => hit.via === null)).toBe(true)
expect(hits.slice(firstIndirect).every((hit) => hit.via !== null)).toBe(true)
}
const keys = index.endpoints.map((entry) => entry.key)
const media = search('media').endpoints.filter((hit) => hit.via === null)
const positions = media.map((hit) => keys.indexOf(hit.entry.key))
expect(positions).toEqual([...positions].sort((a, b) => a - b))
})
it('puts a field-only match after the path matches', () => {
const hits = search('keep_id media').endpoints
expect(routes(hits)).toEqual(['POST /v1/media/merge'])
const mixed = search('title').endpoints
const firstIndirect = mixed.findIndex((hit) => hit.via !== null)
expect(firstIndirect === -1 || mixed.slice(firstIndirect).every((hit) => hit.via)).toBe(
true,
)
})
it('finds sections by title, description and keywords', () => {
expect(routes(search('media').sections)).toEqual(['section Media'])
expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' })
const login = search('login')
expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login'])
expect(routes(login.sections)).toEqual(['section Session only'])
expect(login.sections[0]!.entry).toMatchObject({
locked: true,
description: '25 routes, not available to API keys',
})
})
it('flags session-only routes and sends them to the Session only section', () => {
const entry = search('login').endpoints[0]!.entry as EndpointEntry
expect(entry).toMatchObject({
session: true,
access: 'Session only',
description: 'Log in',
cardId: null,
sectionId: 'session',
})
})
it('finds nothing for xyz', () => {
expect(search('xyz')).toEqual({ endpoints: [], sections: [] })
})
})
describe('suggestions', () => {
it('lists the five suggested rows in order', () => {
expect(routes(suggestedHits(index))).toEqual([
'section Authentication',
'POST /v1/media/',
'section Errors',
'GET /v1/article/',
'PATCH /v1/user/me',
])
})
})
describe('highlightSegments', () => {
it('marks every occurrence of every term', () => {
expect(highlightSegments('POST /v1/media/media', ['media'])).toEqual([
{ text: 'POST /v1/', marked: false },
{ text: 'media', marked: true },
{ text: '/', marked: false },
{ text: 'media', marked: true },
])
})
it('is case-insensitive and escapes regex characters', () => {
expect(highlightSegments('Get {id}', ['get', '{id}'])).toEqual([
{ text: 'Get', marked: true },
{ text: ' ', marked: false },
{ text: '{id}', marked: true },
])
})
it('returns the text unchanged without terms', () => {
expect(highlightSegments('Hello', [])).toEqual([{ text: 'Hello', marked: false }])
})
})
+2
View File
@@ -14,6 +14,8 @@ export const CHIP_CLASS = {
const METHOD_BASE =
'h-5.5 w-14 shrink-0 justify-center rounded-none px-0 py-0 font-mono text-xs font-medium ring-0 border'
export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted`
export const METHOD_CLASS: Record<HttpMethod, string> = {
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
+267
View File
@@ -0,0 +1,267 @@
import { t, translate } from '@/i18n'
import {
API_GROUPS,
ERROR_ROWS,
LOGIN_FAMILY_CAPTION,
PUBLIC_AUTH_ROWS,
SESSION_GROUPS,
type HttpMethod,
} from '@/data/apiCatalogue'
export const ENDPOINT_CAP = 6
export type MatchKind = 'query' | 'body' | 'error'
export interface SearchMatch {
kind: MatchKind
text: string
}
export interface EndpointEntry {
kind: 'endpoint'
key: string
method: HttpMethod
path: string
description: string
access: string
session: boolean
cardId: string | null
sectionId: string
fields: SearchMatch[]
errors: SearchMatch[]
}
export interface SectionEntry {
kind: 'section'
key: string
sectionId: string
title: string
description: string
locked: boolean
keywords: string
}
export type SearchEntry = EndpointEntry | SectionEntry
export interface SearchHit {
entry: SearchEntry
via: SearchMatch | null
}
export interface SearchResult {
endpoints: SearchHit[]
sections: SearchHit[]
}
export interface HighlightSegment {
text: string
marked: boolean
}
export interface SearchIndex {
endpoints: EndpointEntry[]
sections: SectionEntry[]
}
const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me']
export function firstSentence(text: string) {
const end = text.search(/\.(\s|$)/)
return end === -1 ? text : text.slice(0, end + 1)
}
export function queryTerms(query: string) {
return query.toLowerCase().split(/\s+/).filter(Boolean)
}
function endpointEntries(): EndpointEntry[] {
const documented = API_GROUPS.flatMap((group) =>
group.endpoints.map<EndpointEntry>((endpoint) => ({
kind: 'endpoint',
key: `ep:${endpoint.id}`,
method: endpoint.method,
path: endpoint.path,
description: firstSentence(translate(endpoint.description)),
access: translate(endpoint.access),
session: false,
cardId: endpoint.id,
sectionId: group.key,
fields: [
...endpoint.query.map<SearchMatch>((field) => ({
kind: 'query',
text: field.name,
})),
...endpoint.body.map<SearchMatch>((field) => ({ kind: 'body', text: field.name })),
],
errors: endpoint.errors.map<SearchMatch>((error) => ({
kind: 'error',
text: `${error.status} ${translate(error.caption)}`,
})),
})),
)
const sessionOnly = SESSION_GROUPS.flatMap((group) =>
group.rows.map<EndpointEntry>((row) => ({
kind: 'endpoint',
key: `session:${row.method} ${row.path}`,
method: row.method,
path: row.path,
description: firstSentence(
translate(row.description.split(`. ${LOGIN_FAMILY_CAPTION}`)[0]!),
),
access: t('Session only'),
session: true,
cardId: null,
sectionId: 'session',
fields: [],
errors: [],
})),
)
const publicAuth = PUBLIC_AUTH_ROWS.map<EndpointEntry>((row) => ({
kind: 'endpoint',
key: `public:${row.method} ${row.path}`,
method: row.method,
path: row.path,
description: firstSentence(translate(row.description)),
access: t('Public'),
session: false,
cardId: null,
sectionId: 'session',
fields: [],
errors: [],
}))
return [...documented, ...sessionOnly, ...publicAuth]
}
function routeCount(count: number) {
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
}
function sectionEntries(): SectionEntry[] {
const sessionRoutes = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const errorWords = ERROR_ROWS.map((row) => `${row.status} ${translate(row.meaning)}`).join(' ')
const section = (
id: string,
title: string,
description: string,
keywords = '',
locked = false,
): SectionEntry => ({
kind: 'section',
key: `section:${id}`,
sectionId: id,
title,
description,
locked,
keywords,
})
return [
section(
'auth',
t('Authentication'),
t('Public access and API keys'),
'bearer api key rcms rate limit 429 base url public',
),
...API_GROUPS.map((group) =>
section(group.key, translate(group.title), routeCount(group.endpoints.length)),
),
section(
'images',
t('Images and variants'),
t('The ?v= parameter'),
'variant image width height format quality',
),
section(
'session',
t('Session only'),
t('{0} routes, not available to API keys', [sessionRoutes]),
'login log in register password real session',
true,
),
section(
'errors',
t('Errors'),
t('Status codes and messages'),
`${errorWords} status error plain text`,
),
section(
'notes',
t('Notes'),
t('Images, CORS and ?format='),
'cors origins format markdown html json body_format',
),
]
}
export function buildSearchIndex(): SearchIndex {
return { endpoints: endpointEntries(), sections: sectionEntries() }
}
function primaryText(entry: EndpointEntry) {
return `${entry.method} ${entry.path} ${entry.description} ${entry.access}`.toLowerCase()
}
function viaMatch(entry: EndpointEntry, term: string): SearchMatch | null | undefined {
if (primaryText(entry).includes(term)) {
return null
}
const field = entry.fields.find((item) => item.text.toLowerCase().includes(term))
if (field) {
return field
}
return entry.errors.find((item) => item.text.toLowerCase().includes(term))
}
function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null {
let via: SearchMatch | null = null
for (const term of terms) {
const found = viaMatch(entry, term)
if (found === undefined) {
return null
}
via = via ?? found
}
return { entry, via }
}
function matchSection(entry: SectionEntry, terms: string[]): SearchHit | null {
const text = `${entry.title} ${entry.description} ${entry.keywords}`.toLowerCase()
return terms.every((term) => text.includes(term)) ? { entry, via: null } : null
}
export function suggestedHits(index: SearchIndex): SearchHit[] {
const entries: SearchEntry[] = [...index.endpoints, ...index.sections]
return SUGGESTED_KEYS.flatMap((key) => {
const entry = entries.find((item) => item.key === key)
return entry ? [{ entry, via: null }] : []
})
}
export function searchIndex(index: SearchIndex, query: string): SearchResult {
const terms = queryTerms(query)
if (terms.length === 0) {
return { endpoints: [], sections: [] }
}
const hits = index.endpoints.flatMap((entry) => matchEndpoint(entry, terms) ?? [])
const direct = hits.filter((hit) => hit.via === null)
const indirect = hits.filter((hit) => hit.via !== null)
return {
endpoints: [...direct, ...indirect],
sections: index.sections.flatMap((entry) => matchSection(entry, terms) ?? []),
}
}
function escapePattern(term: string) {
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function highlightSegments(text: string, terms: string[]): HighlightSegment[] {
if (terms.length === 0) {
return [{ text, marked: false }]
}
const pattern = new RegExp(`(${terms.map(escapePattern).join('|')})`, 'gi')
const lowered = new Set(terms)
return text
.split(pattern)
.filter((part) => part !== '')
.map((part) => ({ text: part, marked: lowered.has(part.toLowerCase()) }))
}
+204 -27
View File
@@ -1,8 +1,17 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance,
} from 'vue'
import { useRoute } from 'vue-router'
import ApiSearchPalette from '@/components/ApiSearchPalette.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -18,7 +27,8 @@ import {
SESSION_GROUPS,
authExamples,
} from '@/data/apiCatalogue'
import { SECTION_TITLE } from '@/lib/apiReference'
import { SECTION_TITLE, endpointAnchor } from '@/lib/apiReference'
import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch'
const SPY_OFFSET_PX = 120
@@ -124,9 +134,7 @@ function sectionElement(id: string) {
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
}
function goTo(id: string) {
activeSection.value = id
const element = sectionElement(id)
function scrollToElement(element: HTMLElement | null) {
const parent = element ? scrollParent(element) : null
if (!element || !parent) {
element?.scrollIntoView?.({ block: 'start' })
@@ -137,6 +145,11 @@ function goTo(id: string) {
parent.scrollTo({ top: parent.scrollTop + offset - margin })
}
function goTo(id: string) {
activeSection.value = id
scrollToElement(sectionElement(id))
}
function updateActive() {
const element = root.value
const parent = element ? scrollParent(element) : null
@@ -156,18 +169,150 @@ function updateActive() {
activeSection.value = current
}
interface RevealableCard {
reveal: () => Promise<void>
}
const FLASH_MS = 1500
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
const searchOpen = ref(false)
const searchData = computed(() => buildSearchIndex())
const flashedCard = ref<string | null>(null)
const flashMode = ref<'animated' | 'static'>('animated')
const cards = new Map<string, RevealableCard>()
let searchOpener: HTMLElement | null = null
let skipFocusRestore = false
let flashTimer: ReturnType<typeof setTimeout> | undefined
function setCard(id: string, card: Element | ComponentPublicInstance | null) {
if (card) {
cards.set(id, card as unknown as RevealableCard)
} else {
cards.delete(id)
}
}
function openSearch(trigger?: HTMLElement | null) {
const active = document.activeElement
searchOpener = trigger ?? (active instanceof HTMLElement ? active : null)
searchOpen.value = true
}
watch(searchOpen, async (value) => {
if (value) {
return
}
await nextTick()
if (skipFocusRestore) {
skipFocusRestore = false
return
}
searchOpener?.focus()
})
function isEditable(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
const field = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
return (
field ||
target.isContentEditable === true ||
target.closest('[contenteditable=""], [contenteditable="true"]') !== null
)
}
function onShortcut(event: KeyboardEvent) {
if (searchOpen.value || event.defaultPrevented || event.isComposing) {
return
}
const withoutAlt = !event.altKey
const chord =
(event.ctrlKey || event.metaKey) &&
withoutAlt &&
!event.shiftKey &&
event.key.toLowerCase() === 'k'
const slash =
event.key === '/' &&
withoutAlt &&
!event.ctrlKey &&
!event.metaKey &&
!isEditable(event.target)
if (chord || slash) {
event.preventDefault()
openSearch()
}
}
function writeHash(anchor: string) {
const { pathname, search } = window.location
window.history.replaceState(window.history.state, '', `${pathname}${search}#${anchor}`)
}
function startFlash(id: string) {
clearTimeout(flashTimer)
flashMode.value = window.matchMedia?.(REDUCED_MOTION_QUERY).matches ? 'static' : 'animated'
flashedCard.value = id
flashTimer = setTimeout(() => {
flashedCard.value = null
}, FLASH_MS)
}
function revealCard(id: string, sectionId: string) {
activeSection.value = sectionId
void cards.get(id)?.reveal()
scrollToElement(sectionElement(endpointAnchor(id)))
writeHash(endpointAnchor(id))
startFlash(id)
}
function focusSection(id: string) {
goTo(id)
writeHash(id)
const heading = sectionElement(id)?.querySelector<HTMLElement>('h2')
heading?.setAttribute('tabindex', '-1')
heading?.focus({ preventScroll: true })
}
async function onSearchSelect(entry: SearchEntry) {
skipFocusRestore = true
await nextTick()
if (entry.kind === 'endpoint' && entry.cardId) {
revealCard(entry.cardId, entry.sectionId)
return
}
focusSection(entry.sectionId)
}
function revealSharedCard(anchor: string) {
const group = API_GROUPS.find((item) =>
item.endpoints.some((endpoint) => endpointAnchor(endpoint.id) === anchor),
)
if (group) {
revealCard(anchor.slice('ep-'.length), group.key)
}
}
let scroller: HTMLElement | null = null
onMounted(() => {
scroller = root.value ? scrollParent(root.value) : null
scroller?.addEventListener('scroll', updateActive, { passive: true })
document.addEventListener('keydown', onShortcut)
const target = route.hash.slice(1)
if (sections.value.some((section) => section.id === target)) {
nextTick(() => goTo(target))
} else if (target.startsWith('ep-')) {
nextTick(() => revealSharedCard(target))
}
})
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
onBeforeUnmount(() => {
scroller?.removeEventListener('scroll', updateActive)
document.removeEventListener('keydown', onShortcut)
clearTimeout(flashTimer)
})
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
@@ -179,28 +324,48 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<DashboardShell compact-padding :mobile-title="t('Integration')">
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
<div class="flex flex-col gap-1.5">
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1.5">
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
},
},
]"
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
{{ t('Everything a developer needs to build on RenewCMS.') }}
</p>
</div>
<UButton
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
{{ t('Everything a developer needs to build on RenewCMS.') }}
</p>
variant="outline"
:aria-label="t('Search the API reference')"
aria-keyshortcuts="/ Control+K Meta+K"
aria-haspopup="dialog"
class="hidden h-9 w-80 shrink-0 justify-start gap-2 rounded-none border border-accented bg-default py-0 pr-2 pl-3 text-base font-normal text-muted ring-0 hover:border-muted hover:bg-default focus-visible:outline-primary lg:inline-flex"
@click="openSearch($event.currentTarget as HTMLElement)"
>
<UIcon name="i-lucide-search" class="size-4 shrink-0 text-muted" />
<span class="min-w-0 flex-1 truncate text-left">{{
t('Search the API reference…')
}}</span>
<UKbd
value="/"
class="h-5 min-w-5 justify-center rounded-none border border-accented bg-elevated px-1.5 font-mono normal-case text-xs font-medium text-toned ring-0"
/>
</UButton>
</div>
<p class="max-w-180 text-base text-pretty text-toned">
@@ -311,8 +476,10 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<EndpointCard
v-for="endpoint in group.endpoints"
:key="endpoint.id"
:ref="(card) => setCard(endpoint.id, card)"
:endpoint="endpoint"
:origin="origin"
:flash="flashedCard === endpoint.id ? flashMode : null"
/>
</div>
</section>
@@ -407,7 +574,17 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</section>
</div>
<SectionIndex :sections="sections" :active="activeSection" @select="goTo" />
<SectionIndex
:sections="sections"
:active="activeSection"
@select="goTo"
@search="openSearch"
/>
<ApiSearchPalette
v-model:open="searchOpen"
:index="searchData"
@select="onSearchSelect"
/>
</div>
</DashboardShell>
</template>
@@ -0,0 +1,696 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import EndpointCard from '@/components/EndpointCard.vue'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import IntegrationView from '../IntegrationView.vue'
const SEARCH_LABEL = 'Search the API reference'
const FLASH_MS = 1500
let currentWrapper: VueWrapper | null = null
let fetchSpy: ReturnType<typeof vi.fn>
let scrollIntoView: ReturnType<typeof vi.fn>
function stubReducedMotion(reduced: boolean) {
const base = window.matchMedia
window.matchMedia = ((query: string) => {
const media = base(query)
return query.includes('prefers-reduced-motion') ? { ...media, matches: reduced } : media
}) as typeof window.matchMedia
}
async function mountIntegration(width = DESKTOP_WIDTH, hash = '') {
setViewportWidth(width)
stubReducedMotion(false)
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(`/integration${hash}`)
const wrapper = mount(IntegrationView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function press(target: EventTarget, key: string, init: KeyboardEventInit = {}) {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
target.dispatchEvent(event)
return event
}
function searchInput() {
return document.body.querySelector<HTMLInputElement>('input[role="combobox"]')
}
function paletteOpen() {
return searchInput() !== null
}
async function openWithSlash() {
press(document.body, '/')
await flushPromises()
}
async function type(text: string) {
const input = searchInput()!
input.value = text
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function pressInPalette(key: string) {
press(searchInput()!, key)
await flushPromises()
}
function options() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
}
function optionRoutes() {
return options().map((option) => option.getAttribute('aria-label')!.split('. ')[0])
}
function selectedIndex() {
return options().findIndex((option) => option.getAttribute('aria-selected') === 'true')
}
function liveRegion() {
return document.body.querySelector('[role="status"]')!.textContent
}
function groupTitles() {
return Array.from(document.body.querySelectorAll('[role="group"]')).map((group) => {
const id = group.getAttribute('aria-labelledby')!
return document.getElementById(id)!.textContent!.trim()
})
}
function cardFor(wrapper: VueWrapper, id: string) {
return wrapper.findAllComponents(EndpointCard).find((item) => item.props('endpoint').id === id)!
}
function openCards(wrapper: VueWrapper) {
return wrapper
.findAll('[data-anchor^="ep-"] button[aria-expanded="true"]')
.map((button) => button.attributes('aria-label'))
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.unstubAllGlobals()
vi.useRealTimers()
document.body.innerHTML = ''
window.history.replaceState(null, '', '/')
await router.push('/login')
})
async function settle() {
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
}
describe('Desktop trigger', () => {
it('shows the field-style button with placeholder, key hint and shortcuts', async () => {
const wrapper = await mountIntegration()
const trigger = wrapper.get(`button[aria-label="${SEARCH_LABEL}"]`)
expect(trigger.text()).toContain('Search the API reference…')
expect(trigger.text()).toContain('/')
expect(trigger.attributes('aria-keyshortcuts')).toBe('/ Control+K Meta+K')
expect(trigger.classes()).toContain('lg:inline-flex')
expect(trigger.classes()).toContain('hidden')
expect(wrapper.find('.sticky button[aria-label="Search the API reference"]').exists()).toBe(
true,
)
})
it('is not inside the sticky bar', async () => {
const wrapper = await mountIntegration()
const desktopTriggers = wrapper
.findAll(`button[aria-label="${SEARCH_LABEL}"]`)
.filter((button) => button.attributes('aria-keyshortcuts'))
expect(desktopTriggers).toHaveLength(1)
expect(desktopTriggers[0]!.element.closest('.sticky')).toBeNull()
})
})
describe('Mobile trigger', () => {
it('sits beside Jump to as an icon-only button', async () => {
const wrapper = await mountIntegration(MOBILE_WIDTH)
const bar = wrapper.get('button[aria-label="Jump to section"]').element.closest('.sticky')!
const icon = bar.querySelector(`button[aria-label="${SEARCH_LABEL}"]`)!
expect(icon.textContent?.trim()).toBe('')
expect(icon.classList.contains('size-11')).toBe(true)
const desktop = wrapper
.findAll(`button[aria-label="${SEARCH_LABEL}"]`)
.find((button) => button.attributes('aria-keyshortcuts'))!
expect(desktop.classes()).toContain('hidden')
expect(desktop.classes()).toContain('lg:inline-flex')
})
it('opens the full-screen sheet with a Cancel button that returns focus to the trigger', async () => {
const wrapper = await mountIntegration(MOBILE_WIDTH)
const bar = wrapper.get('button[aria-label="Jump to section"]').element.closest('.sticky')!
const icon = bar.querySelector<HTMLButtonElement>(`button[aria-label="${SEARCH_LABEL}"]`)!
icon.focus()
icon.click()
await settle()
expect(paletteOpen()).toBe(true)
const cancel = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Cancel',
)
expect(cancel).toBeInstanceOf(HTMLButtonElement)
cancel!.click()
await settle()
expect(paletteOpen()).toBe(false)
expect(document.activeElement).toBe(icon)
})
})
describe('Opening the palette with a shortcut', () => {
it.each([
['/ on the page body', () => document.body, '/', {}],
['Ctrl+K on the page body', () => document.body, 'k', { ctrlKey: true }],
['Cmd+K on the page body', () => document.body, 'k', { metaKey: true }],
])('%s opens the palette', async (_name, target, key, init) => {
await mountIntegration()
const event = press(target(), key, init)
await settle()
expect(paletteOpen()).toBe(true)
expect(event.defaultPrevented).toBe(true)
})
it('opens with / from a button and does not type the slash', async () => {
const wrapper = await mountIntegration()
const button = wrapper.get('button[aria-expanded]').element as HTMLButtonElement
button.focus()
const event = press(button, '/')
await settle()
expect(paletteOpen()).toBe(true)
expect(event.defaultPrevented).toBe(true)
expect(searchInput()!.value).toBe('')
})
it('ignores / in a text input, a textarea and a contenteditable', async () => {
await mountIntegration()
const input = document.createElement('input')
const textarea = document.createElement('textarea')
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
document.body.append(input, textarea, editable)
for (const field of [input, textarea, editable]) {
const event = press(field, '/')
await settle()
expect(event.defaultPrevented).toBe(false)
expect(paletteOpen()).toBe(false)
}
})
it('opens with Ctrl+K from inside a text input', async () => {
await mountIntegration()
const input = document.createElement('input')
document.body.append(input)
const event = press(input, 'k', { ctrlKey: true })
await settle()
expect(paletteOpen()).toBe(true)
expect(event.defaultPrevented).toBe(true)
})
it('removes the shortcuts when the user leaves the page', async () => {
const wrapper = await mountIntegration()
wrapper.unmount()
currentWrapper = null
const slash = press(document.body, '/')
const chord = press(document.body, 'k', { ctrlKey: true })
await settle()
expect(paletteOpen()).toBe(false)
expect(slash.defaultPrevented).toBe(false)
expect(chord.defaultPrevented).toBe(false)
})
it('leaves Ctrl+S alone', async () => {
await mountIntegration()
const event = press(document.body, 's', { ctrlKey: true })
await settle()
expect(event.defaultPrevented).toBe(false)
expect(paletteOpen()).toBe(false)
})
})
describe('Opening focuses the input and closing restores focus', () => {
it('focuses the input and returns focus to the desktop trigger on Escape', async () => {
const wrapper = await mountIntegration()
const trigger = wrapper.get(`button[aria-keyshortcuts]`).element as HTMLButtonElement
trigger.focus()
trigger.click()
await settle()
expect(document.activeElement).toBe(searchInput())
await pressInPalette('Escape')
expect(paletteOpen()).toBe(false)
expect(document.activeElement).toBe(trigger)
})
it('returns focus to the element that had it when opened with the keyboard', async () => {
const wrapper = await mountIntegration()
const other = wrapper.get('button[aria-expanded]').element as HTMLButtonElement
other.focus()
press(other, 'k', { ctrlKey: true })
await settle()
await pressInPalette('Escape')
expect(document.activeElement).toBe(other)
})
})
describe('8b Open, empty query', () => {
it('shows the five suggested rows, the first selected, and 5 suggestions', async () => {
await mountIntegration()
await openWithSlash()
expect(searchInput()!.getAttribute('placeholder')).toBe('Search the API reference…')
expect(groupTitles()).toEqual(['Suggested'])
expect(options().map((option) => option.getAttribute('aria-label'))).toEqual([
expect.stringMatching(/^Section Authentication\. Public access and API keys$/),
expect.stringMatching(
/^POST \/v1\/media\/\. Upload an image \(multipart\)\.\. Verified user$/,
),
expect.stringMatching(/^Section Errors\. Status codes and messages$/),
expect.stringMatching(/^GET \/v1\/article\/\. .*\. Public$/),
expect.stringMatching(/^PATCH \/v1\/user\/me\. Update my profile\.\. Verified user$/),
])
expect(selectedIndex()).toBe(0)
expect(document.body.textContent).toContain('5 suggestions')
expect(liveRegion()).toBe('5 suggestions')
expect(document.body.textContent).toContain('Move')
expect(document.body.textContent).toContain('Open')
expect(document.body.textContent).toContain('Close')
})
it('stores nothing as a recent search', async () => {
await mountIntegration()
const stored = JSON.stringify({ ...localStorage })
await openWithSlash()
await type('media')
expect(JSON.stringify({ ...localStorage })).toBe(stored)
expect(sessionStorage.length).toBe(0)
})
})
describe('8c Typing media', () => {
it('lists endpoints then the Media section, with highlights and the count', async () => {
await mountIntegration()
await openWithSlash()
await type('media')
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
const rows = optionRoutes()
expect(rows.slice(0, 6).every((route) => route!.includes('/v1/media'))).toBe(true)
expect(rows.at(-1)).toBe('Section Media')
expect(options().at(-1)!.textContent).toContain('11 routes')
expect(document.body.textContent).toContain('12 results')
expect(liveRegion()).toBe('12 results, showing 7')
expect(document.body.textContent).toContain(
'5 more endpoints. Keep typing to narrow the list.',
)
const marks = Array.from(document.body.querySelectorAll('[role="option"] mark'))
expect(marks.length).toBeGreaterThan(0)
expect(marks.every((mark) => mark.textContent!.toLowerCase() === 'media')).toBe(true)
expect(marks[0]!.className).toContain('--rc-sand')
expect(marks[0]!.className).toContain('--rc-on-sand')
})
})
describe('8d More endpoints than the cap', () => {
it('lists six rows with an Error line, the Errors section and the cap line', async () => {
await mountIntegration()
await openWithSlash()
await type('404')
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
const endpointRows = options().slice(0, -1)
expect(endpointRows).toHaveLength(6)
expect(endpointRows.every((row) => row.textContent!.includes('Error'))).toBe(true)
expect(endpointRows[0]!.textContent).toMatch(/Error\s*404 /)
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
'Section Errors. Status codes and messages',
)
const total = Number(/(\d+) results/.exec(document.body.textContent!)![1])
expect(total).toBeGreaterThan(7)
expect(document.body.textContent).toContain(
`${total - 7} more endpoints. Keep typing to narrow the list.`,
)
expect(liveRegion()).toBe(`${total} results, showing 7`)
})
})
describe('8e Match on a body field', () => {
it('lists only POST /v1/media/merge with the body field line', async () => {
await mountIntegration()
await openWithSlash()
await type('keep_id')
expect(optionRoutes()).toEqual(['POST /v1/media/merge'])
const row = options()[0]!
expect(row.textContent).toContain('Merge identical copies into one image.')
expect(row.textContent).toContain('Verified user')
expect(row.textContent).toContain('Body field')
expect(row.querySelector('mark')!.textContent).toBe('keep_id')
expect(document.body.textContent).toContain('1 result')
expect(liveRegion()).toBe('1 result')
})
it('labels a query parameter', async () => {
await mountIntegration()
await openWithSlash()
await type('hash')
const row = options().find((item) =>
item.getAttribute('aria-label')!.startsWith('GET /v1/media/. '),
)!
expect(row.textContent).toContain('Query parameter')
})
it('shows no second line for a path match', async () => {
await mountIntegration()
await openWithSlash()
await type('merge')
expect(options()[0]!.textContent).not.toContain('Body field')
})
})
describe('8f Session-only route', () => {
it('styles the route as session only and lists the Session only section', async () => {
await mountIntegration()
await openWithSlash()
await type('login')
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
const [route, section] = options()
expect(route!.getAttribute('aria-label')).toBe('POST /v1/auth/login. Log in. Session only')
expect(route!.textContent).toContain('Session only')
expect(route!.querySelector('[data-slot="leadingIcon"]')).not.toBeNull()
const path = route!.querySelector('mark')!.parentElement!
expect(path.classList.contains('font-normal')).toBe(true)
expect(route!.innerHTML).toContain('border-dashed')
expect(section!.getAttribute('aria-label')).toBe(
'Section Session only. 25 routes, not available to API keys',
)
expect(section!.querySelector('svg')).not.toBeNull()
expect(document.body.textContent).toContain('2 results')
})
})
describe('8g No results', () => {
it('shows the empty copy, 0 results and No results', async () => {
await mountIntegration()
await openWithSlash()
await type('xyz')
expect(options()).toHaveLength(0)
expect(document.body.textContent).toContain(
'No match for "xyz". Try a path, a method or a status code like 404.',
)
expect(document.body.textContent).toContain('0 results')
expect(liveRegion()).toBe('No results')
})
it('does not match a misspelled term', async () => {
await mountIntegration()
await openWithSlash()
await type('mdia')
expect(options()).toHaveLength(0)
})
})
describe('Keyboard navigation', () => {
async function showSeven() {
await mountIntegration()
await openWithSlash()
await type('media')
return options().length
}
it('moves the selection with the arrows and wraps', async () => {
const count = await showSeven()
expect(count).toBe(7)
await pressInPalette('ArrowDown')
expect(selectedIndex()).toBe(1)
expect(document.activeElement).toBe(searchInput())
await pressInPalette('ArrowUp')
await pressInPalette('ArrowUp')
expect(selectedIndex()).toBe(6)
await pressInPalette('ArrowDown')
expect(selectedIndex()).toBe(0)
})
it('jumps to the first and last row with Home and End', async () => {
await showSeven()
await pressInPalette('ArrowDown')
await pressInPalette('ArrowDown')
await pressInPalette('End')
expect(selectedIndex()).toBe(6)
await pressInPalette('Home')
expect(selectedIndex()).toBe(0)
})
it('resets the selection to the first row when typing', async () => {
await showSeven()
await pressInPalette('ArrowDown')
await pressInPalette('ArrowDown')
expect(selectedIndex()).toBe(2)
await type('medi')
expect(selectedIndex()).toBe(0)
})
it('follows the selection with aria-activedescendant', async () => {
await showSeven()
await pressInPalette('ArrowDown')
expect(searchInput()!.getAttribute('aria-activedescendant')).toBe(options()[1]!.id)
})
it('closes on Escape without scrolling or changing the hash', async () => {
await showSeven()
window.history.replaceState(null, '', '/integration#media')
await pressInPalette('Escape')
expect(paletteOpen()).toBe(false)
expect(scrollIntoView).not.toHaveBeenCalled()
expect(window.location.hash).toBe('#media')
})
})
describe('Roles and live region', () => {
it('exposes the combobox, listbox, groups and options', async () => {
await mountIntegration()
await openWithSlash()
await type('media')
const input = searchInput()!
const list = document.body.querySelector('[role="listbox"]')!
expect(input.getAttribute('aria-expanded')).toBe('true')
expect(input.getAttribute('aria-controls')).toBe(list.id)
expect(input.getAttribute('aria-autocomplete')).toBe('list')
expect(input.getAttribute('aria-activedescendant')).toBe(options()[0]!.id)
expect(document.body.querySelectorAll('[role="group"]')).toHaveLength(2)
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
expect(options()[1]!.getAttribute('aria-selected')).toBe('false')
expect(options()[0]!.getAttribute('aria-label')).toMatch(/^[A-Z]+ \/v1\/.*\. .*\. .+$/)
expect(document.body.querySelector('[role="status"]')!.getAttribute('aria-live')).toBe(
'polite',
)
})
})
describe('8h Selecting an endpoint', () => {
async function selectMerge(wrapper: VueWrapper, how: 'enter' | 'click') {
await openWithSlash()
await type('keep_id')
if (how === 'enter') {
await pressInPalette('Enter')
} else {
options()[0]!.click()
await settle()
}
await wrapper.vm.$nextTick()
await settle()
}
it.each(['enter', 'click'] as const)(
'closes, scrolls, expands, focuses, flashes and writes the hash with %s',
async (how) => {
const wrapper = await mountIntegration()
const historyLength = window.history.length
const card = cardFor(wrapper, 'med-merge')
const replaceState = vi.spyOn(window.history, 'replaceState')
await selectMerge(wrapper, how)
expect(paletteOpen()).toBe(false)
expect(scrollIntoView.mock.contexts).toContain(
wrapper.get('[data-anchor="ep-med-merge"]').element,
)
const button = card.get('button[aria-expanded]')
expect(button.attributes('aria-expanded')).toBe('true')
expect(document.activeElement).toBe(button.element)
expect(button.classes()).toContain('endpoint-flash')
expect(window.location.hash).toBe('#ep-med-merge')
expect(replaceState).toHaveBeenCalled()
expect(window.history.length).toBe(historyLength)
expect(openCards(wrapper)).toHaveLength(1)
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.get('button[aria-current="true"]').text()).toBe('Media')
await vi.advanceTimersByTimeAsync(FLASH_MS)
await flushPromises()
expect(button.classes()).not.toContain('endpoint-flash')
expect(button.attributes('aria-expanded')).toBe('true')
},
)
it('keeps the other cards as they were', async () => {
const wrapper = await mountIntegration()
await wrapper.get('button[aria-label^="GET /v1/media/. "]').trigger('click')
await flushPromises()
await selectMerge(wrapper, 'enter')
expect(openCards(wrapper)).toHaveLength(2)
})
it('uses a static ring with reduced motion', async () => {
const wrapper = await mountIntegration()
stubReducedMotion(true)
await selectMerge(wrapper, 'enter')
const button = cardFor(wrapper, 'med-merge').get('button[aria-expanded]')
expect(button.classes()).toContain('endpoint-flash-static')
expect(button.classes()).not.toContain('endpoint-flash')
})
})
describe('Selecting a section', () => {
it('focuses the heading, writes the hash and expands nothing', async () => {
const wrapper = await mountIntegration()
await openWithSlash()
await type('errors')
await pressInPalette('Enter')
await settle()
expect(paletteOpen()).toBe(false)
expect(scrollIntoView.mock.contexts).toContain(
wrapper.get('[data-anchor="errors"]').element,
)
expect(document.activeElement).toBe(wrapper.get('#h-errors').element)
expect(window.location.hash).toBe('#errors')
expect(openCards(wrapper)).toHaveLength(0)
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
})
})
describe('Selecting a session-only route', () => {
it('goes to the Session only heading without expanding a card', async () => {
const wrapper = await mountIntegration()
await openWithSlash()
await type('login')
await pressInPalette('Enter')
await settle()
expect(document.activeElement).toBe(wrapper.get('#h-session').element)
expect(openCards(wrapper)).toHaveLength(0)
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
})
})
describe('Shared link', () => {
it('scrolls to, expands, focuses and flashes the card without opening the palette', async () => {
const wrapper = await mountIntegration(DESKTOP_WIDTH, '#ep-med-merge')
await settle()
const button = cardFor(wrapper, 'med-merge').get('button[aria-expanded]')
expect(paletteOpen()).toBe(false)
expect(button.attributes('aria-expanded')).toBe('true')
expect(document.activeElement).toBe(button.element)
expect(button.classes()).toContain('endpoint-flash')
expect(scrollIntoView.mock.contexts).toContain(
wrapper.get('[data-anchor="ep-med-merge"]').element,
)
})
it('does nothing for an unknown id', async () => {
const wrapper = await mountIntegration(DESKTOP_WIDTH, '#ep-nope')
await settle()
expect(openCards(wrapper)).toHaveLength(0)
expect(paletteOpen()).toBe(false)
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
})
})
describe('The page is never filtered', () => {
it('keeps every section and card in the page while a query is typed', async () => {
const wrapper = await mountIntegration()
const sections = wrapper.findAll('[data-anchor]:not([data-anchor^="ep-"])').length
const cards = wrapper.findAllComponents(EndpointCard).length
const calls = fetchSpy.mock.calls.length
await openWithSlash()
await type('keep_id')
expect(wrapper.findAll('[data-anchor]:not([data-anchor^="ep-"])')).toHaveLength(sections)
expect(wrapper.findAllComponents(EndpointCard)).toHaveLength(cards)
expect(fetchSpy.mock.calls.length).toBe(calls)
})
})
+15
View File
@@ -230,6 +230,21 @@ const extraSources: Record<string, Source[]> = {
},
],
integration: [
{
board: officeBoard,
group: 'Integration',
states: [
'8a. Search: trigger focused',
'8b. Search: open, empty',
'8c. Search: typing, results',
'8d. Search: keyboard-selected, status code',
'8e. Search: body field match',
'8f. Search: session-only results',
'8g. Search: no results',
'8h. Search: after selecting an endpoint',
],
alias: {},
},
{
board: officeBoard,
group: 'Integration: images',
+60
View File
@@ -1246,6 +1246,45 @@ function integrationState(overrides: Partial<VisualState> = {}): VisualState {
}
}
const integrationSearchLabel = 'Search the API reference'
const desktopMinWidth = 1024
async function focusIntegrationTrigger(page: Page) {
const width = page.viewportSize()?.width ?? 0
if (width >= desktopMinWidth) {
await page.getByRole('button', { name: integrationSearchLabel, exact: true }).focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
}
}
async function openIntegrationSearch(page: Page) {
await page.getByRole('button', { name: integrationSearchLabel, exact: true }).click()
await page.getByRole('combobox').waitFor()
await page.mouse.move(0, 0)
}
function searchingIntegration(query: string, arrowDowns = 0): VisualState['steps'] {
return async (page) => {
await openIntegrationSearch(page)
await page.getByRole('combobox').fill(query)
for (let press = 0; press < arrowDowns; press++) {
await page.keyboard.press('ArrowDown')
}
}
}
async function selectIntegrationMerge(page: Page) {
const flash = page.locator('.endpoint-flash, .endpoint-flash-static')
await page.addStyleTag({ content: '.endpoint-flash { animation: none !important; }' })
await openIntegrationSearch(page)
await page.getByRole('combobox').fill('keep_id')
await page.keyboard.press('Enter')
await flash.waitFor()
await flash.waitFor({ state: 'detached' })
await page.mouse.move(0, 0)
}
function integrationImagesState(
list: ApiMock | null,
overrides: Partial<VisualState> & { session?: ApiMock } = {},
@@ -8678,6 +8717,27 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
approvedBy: 'fsylvain',
},
}),
'8a. Search: trigger focused': integrationState({ steps: focusIntegrationTrigger }),
'8b. Search: open, empty': integrationState({ steps: searchingIntegration('') }),
'8c. Search: typing, results': integrationState({ steps: searchingIntegration('media') }),
'8d. Search: keyboard-selected, status code': integrationState({
steps: searchingIntegration('404', 2),
}),
'8e. Search: body field match': integrationState({
steps: searchingIntegration('keep_id'),
}),
'8f. Search: session-only results': integrationState({
steps: searchingIntegration('login'),
}),
'8g. Search: no results': integrationState({ steps: searchingIntegration('xyz') }),
'8h. Search: after selecting an endpoint': integrationState({
steps: selectIntegrationMerge,
exception: {
maxPercent: 50,
reason: 'Expanded card body predates the search slice: Query parameters block and four-column field table differ from the board, and the real catalogue lists more media routes',
approvedBy: 'Florian Sylvain',
},
}),
'I1 · Many variants': integrationImagesState(integrationManyVariants),
'I2 · One variant': integrationImagesState(
integrationVariantsList(integrationVariantRows.slice(0, 1)),