mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
fix(dashboard): give every dialog focus, a Tab trap and Escape
Escape went through closeOverlays(), which reset nine overlays and left the rest (top-up, request a tool, list as vendor, try an endpoint, the access question, recipes, call details, the resource picker, the agent guide, sign in) open. Focus moved in for nine of them only, Tab walked out into the page behind every one, and closing left focus on the page body. One directive, v-dialog on each role="dialog" element, now does it for all: focus goes to the first field (else the dialog), Tab and Shift+Tab stay inside, Escape closes the topmost dialog through the close function it was given, and focus returns to the control that opened it. The first-run welcome and invite choice pass no close function, so Escape leaves them. The per-dialog focus watchers go, and closeOverlays() now only closes the page's menus. The sign-in dialog mounts with v-if instead of a class, so the directive sees it open and close. Browser tests open the top-up, list-as-vendor, request-a-tool, new-team and sign-in dialogs, press Tab and Shift+Tab twenty times each, and press Escape; every one failed before.
This commit is contained in:
@@ -23,6 +23,7 @@ sources:
|
||||
- frontend/src/dialogs/ConnectTokenDialog.vue
|
||||
- frontend/src/dialogs/ConnectionMethodDialog.vue
|
||||
- frontend/src/dialogs/CopyToolDialog.vue
|
||||
- frontend/src/dialogs/dialog.ts
|
||||
- frontend/src/dialogs/EditToolDialog.vue
|
||||
- frontend/src/dialogs/ExtraCredentialDialog.vue
|
||||
- frontend/src/dialogs/ImportSkillDialog.vue
|
||||
@@ -301,7 +302,13 @@ Copy failures, including unavailable clipboard APIs, surface a dismissible messa
|
||||
or outside interaction. The search entry on Getting started navigates to Catalog and focuses the
|
||||
existing search field. Team settings and switching retain the existing `orgSettings` / `switchTo`
|
||||
behavior, including fixed-position dropdown placement via `placeOrgMenu`. The team picker supports
|
||||
Enter and Space; Escape restores focus to its trigger. Direct `go` navigation returns to the top of
|
||||
Enter and Space; Escape restores focus to its trigger. Every modal and drawer carries `v-dialog`
|
||||
(`dialogs/dialog.ts`, registered in `main.ts`) on its `role="dialog"` element, one keyboard contract
|
||||
instead of per-dialog code: focus moves to its first field (else the dialog itself), Tab and
|
||||
Shift+Tab stay inside, Escape closes the topmost open dialog through the close function it was
|
||||
given, and focus returns to the control that opened it. A required decision (the first-run welcome,
|
||||
the first-run invite choice) passes no close function and survives Escape. `closeOverlays` only
|
||||
closes the page's menus. Direct `go` navigation returns to the top of
|
||||
the destination; Back/Forward leaves scroll restoration to the browser. Category/team tabs and wide
|
||||
tables scroll locally on small screens, and the onboarding OAuth divider wraps instead of widening
|
||||
the page. At phone width inline `code` (a hub tool's `uses`) breaks anywhere, the top-up amounts
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test'
|
||||
import { billingOn, openTopUp, signIn } from './helpers'
|
||||
|
||||
// Every dialog: focus moves in when it opens, Tab and Shift+Tab stay inside, Escape closes it,
|
||||
// and focus returns to the control that opened it.
|
||||
|
||||
const focusInside = (dialog: Locator) => dialog.evaluate(el => el.contains(document.activeElement))
|
||||
|
||||
async function expectTrapped(page: Page, dialog: Locator) {
|
||||
expect(await focusInside(dialog)).toBe(true)
|
||||
for (const key of ['Tab', 'Shift+Tab']) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.keyboard.press(key)
|
||||
expect(await focusInside(dialog), `${key} #${i + 1}`).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('the top-up dialog takes focus, keeps it, and gives it back on Escape', async ({ page }) => {
|
||||
await billingOn(page)
|
||||
await signIn(page, 'dialog-topup')
|
||||
await openTopUp(page)
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expectTrapped(page, dialog)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Top up', exact: true })).toBeFocused()
|
||||
})
|
||||
|
||||
test('catalog dialogs close on Escape and return focus to their trigger', async ({ page }) => {
|
||||
await signIn(page, 'dialog-catalog')
|
||||
await page.getByRole('navigation', { name: 'Primary navigation' }).getByRole('button', { name: 'Catalog', exact: true }).click()
|
||||
for (const trigger of ['List as vendor', 'Request a tool']) {
|
||||
const button = page.getByRole('button', { name: trigger, exact: true })
|
||||
await button.click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expectTrapped(page, dialog)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(button).toBeFocused()
|
||||
}
|
||||
})
|
||||
|
||||
test('the new-team dialog starts in its name field and closes on Escape', async ({ page }) => {
|
||||
await signIn(page, 'dialog-team')
|
||||
await page.getByRole('button', { name: 'Teams' }).click()
|
||||
await page.getByRole('button', { name: '+ New team' }).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByPlaceholder('Team name, e.g. Superdesign')).toBeFocused()
|
||||
await expectTrapped(page, dialog)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the sign-in dialog closes on Escape', async ({ page }) => {
|
||||
await page.goto('/catalog')
|
||||
const start = page.getByRole('button', { name: 'Start free', exact: true })
|
||||
await start.click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Sign in' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expectTrapped(page, dialog)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(start).toBeFocused()
|
||||
})
|
||||
@@ -138,7 +138,7 @@ export default { ...controller, components: { ...controller.components, TeamReso
|
||||
<button v-if="keyMenu.key.can_hide && keyMenu.key.state==='revoked'" role="menuitem" @click="requestKeyAction(keyMenu.key,'hide')">Hide</button>
|
||||
</div>
|
||||
|
||||
<div v-if="keyConfirm" class="scrim" role="dialog" aria-modal="true" aria-labelledby="key-confirm-title" @click.self="keyConfirm=null">
|
||||
<div v-if="keyConfirm" class="scrim" role="dialog" aria-modal="true" aria-labelledby="key-confirm-title" v-dialog="() => { keyConfirm=null }" @click.self="keyConfirm=null">
|
||||
<div class="modal" style="width:min(470px,94vw);padding:18px 20px">
|
||||
<h3 id="key-confirm-title" style="margin:0">{{keyConfirm.action==='rotate'?'Rotate':keyConfirm.action==='disable'?'Disable':keyConfirm.action==='revoke'?'Revoke':'Hide'}} “{{keyConfirm.key.name}}”?</h3>
|
||||
<p v-if="keyConfirm.action==='rotate'" class="sub" style="margin:12px 0 0"><template v-if="keyConfirm.key.kind==='default_human'">This team's current Getting Started token will stop working immediately. The replacement will be shown next and remain revealable on Getting Started.</template><template v-else>The current key will stop working immediately. The replacement will be shown next so you can update every client using it.</template></p>
|
||||
@@ -174,7 +174,7 @@ export default { ...controller, components: { ...controller.components, TeamReso
|
||||
<EditToolDialog v-if="newTool" />
|
||||
|
||||
<!-- JOIN BY CODE -->
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="showJoin" @click.self="showJoin=false">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="showJoin" v-dialog="() => { showJoin=false }" @click.self="showJoin=false">
|
||||
<div class="modal" style="width:min(460px,92vw)"><div class="hd"><b>Join with an invite code</b><button class="btn sm" @click="showJoin=false" aria-label="Close">✕</button></div>
|
||||
<div style="padding:18px"><p class="sub" style="margin-top:0">Paste the one-time code an admin gave you. It must match your email (<span class="mono">{{me}}</span>). Invites addressed to you also appear automatically as a banner.</p>
|
||||
<div class="field"><input v-model="joinCode" placeholder="one-time invite code" @keyup.enter="joinByCode"/></div>
|
||||
@@ -192,7 +192,7 @@ export default { ...controller, components: { ...controller.components, TeamReso
|
||||
<WelcomeDialog v-if="welcome.on" />
|
||||
|
||||
<!-- CREATE TEAM -->
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="newOrg" @click.self="newOrg=false">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="newOrg" v-dialog="() => { newOrg=false }" @click.self="newOrg=false">
|
||||
<div class="modal" style="width:min(440px,92vw)"><div class="hd"><b>Create a team</b><button class="btn sm" @click="newOrg=false" aria-label="Close">✕</button></div>
|
||||
<div style="padding:18px"><p class="sub" style="margin-top:0">You'll be its owner - invite teammates after.</p>
|
||||
<div class="field"><input v-model="newOrgName" placeholder="Team name, e.g. Superdesign" @keyup.enter="createOrg"/></div>
|
||||
@@ -202,7 +202,7 @@ export default { ...controller, components: { ...controller.components, TeamReso
|
||||
</div>
|
||||
|
||||
<!-- ADD ORG -->
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="addOrg" @click.self="addOrg=false">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="addOrg" v-dialog="() => { addOrg=false }" @click.self="addOrg=false">
|
||||
<div class="modal" style="width:min(440px,92vw)"><div class="hd"><b>Add an organization</b><button class="btn sm" @click="addOrg=false" aria-label="Close">✕</button></div>
|
||||
<div style="padding:18px"><p class="sub" style="margin-top:0">Paste that org's token (each org has its own).</p>
|
||||
<div class="field"><input v-model="tokenInput" type="password" placeholder="X-Treg-Token"/></div>
|
||||
@@ -215,7 +215,7 @@ export default { ...controller, components: { ...controller.components, TeamReso
|
||||
<CopyToolDialog v-if="copyTool" />
|
||||
|
||||
<!-- INSTALL A RECIPE (recipe-only bundle: how to install/use, no proxy call) -->
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="copyRecipe" @click.self="copyRecipe=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="copyRecipe" v-dialog="() => { copyRecipe=null }" @click.self="copyRecipe=null">
|
||||
<div class="modal"><div class="hd"><b>Install “{{copyRecipe.name}}”</b><button class="btn sm ico" @click="copyRecipe=null" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<div class="tabs"><button v-for="t in ['cURL','CLI','Claude Code']" :key="t" :class="{active:recipeTab===t}" @click="recipeTab=t">{{t}}</button></div>
|
||||
@@ -226,7 +226,7 @@ export default { ...controller, components: { ...controller.components, TeamReso
|
||||
</div>
|
||||
|
||||
<!-- AGENT SETUP GUIDE (an instruction to paste into a coding agent) -->
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="agentGuide" @click.self="agentGuide=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-if="agentGuide" v-dialog="() => { agentGuide=null }" @click.self="agentGuide=null">
|
||||
<div class="modal" style="width:min(680px,95vw)"><div class="hd"><b>{{agentGuide==='admin'?'Sync your skills & secrets':'Use your team’s shared tools'}}</b><button class="btn sm ico" @click="agentGuide=null" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<p class="explain">Paste this into your coding agent (Claude Code / Codex / Gemini). One line — the agent reads llms.txt and does the rest: installs the CLI, signs in as you, and makes its first call. No API keys land on your machine.</p>
|
||||
|
||||
@@ -5,8 +5,8 @@ export default { components: { BrandMark }, setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lc-scrim" :class="{open:demo.signin}" @click.self="demo.signin=false">
|
||||
<div class="lc-modal" role="dialog" aria-modal="true" aria-label="Sign in">
|
||||
<div v-if="demo.signin" class="lc-scrim open" @click.self="demo.signin=false">
|
||||
<div class="lc-modal" role="dialog" aria-modal="true" aria-label="Sign in" v-dialog="() => { demo.signin=false }">
|
||||
<button class="cls" @click="demo.signin=false" aria-label="Close">✕</button>
|
||||
<div style="font-size:22px;line-height:1"><BrandMark/></div>
|
||||
<h2 style="margin:8px 0 2px;font-family:var(--mono)">{{oauthSignin?'Sign in to continue connecting Treg':(invitePrefill?'Accept your invite':(shareGate?'Sign in to view it':(publicCatalog?'Start calling':'Make it yours')))}}</h2>
|
||||
|
||||
@@ -5,7 +5,7 @@ export default { components: { BrandMark }, setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" >
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="inviteFirstRun ? null : declineInvite">
|
||||
<div class="modal" style="width:min(470px,94vw)">
|
||||
<div style="padding:26px 26px 22px">
|
||||
<div class="brand" style="color:var(--accent);font-size:15px;letter-spacing:.5px;margin-bottom:12px"><BrandMark/>treg</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="capAsk=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { capAsk=null }" @click.self="capAsk=null">
|
||||
<div class="modal" style="padding:16px">
|
||||
<h3 style="margin:0 0 6px">Connect {{capAsk.provider.display_name}}</h3>
|
||||
<p class="sub" style="margin:0 0 14px">What should your agent be allowed to do with this account?
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="callView=null" style="place-items:stretch;justify-items:end">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { callView=null }" @click.self="callView=null" style="place-items:stretch;justify-items:end">
|
||||
<div class="drawer" style="width:min(680px,96vw)"><div class="hd" style="padding:15px 18px;border-bottom:1px solid var(--line)"><b>{{callView.endpoint_id||callView.tool||'Call'}}</b><button class="btn sm" @click="callView=null" aria-label="Close">✕</button></div>
|
||||
<div class="bd" style="padding:16px 18px;overflow:auto">
|
||||
<div class="kv">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="tokenAsk=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { tokenAsk=null }" @click.self="tokenAsk=null">
|
||||
<div class="modal" style="padding:16px">
|
||||
<h3 style="margin:0 0 6px"><span class="plogo-tile"><img class="plogo" :src="'/logos/'+tokenAsk.provider.service+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>Connect {{tokenAsk.provider.display_name}}</h3>
|
||||
<p class="sub" style="margin:0 0 12px">You bring your own {{tokenAsk.provider.auth_kind==='key'?'API key':'bot'}}, so it stays yours — treg
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="method-ask-title" @click.self="methodAsk=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="method-ask-title" v-dialog="() => { methodAsk=null }" @click.self="methodAsk=null">
|
||||
<div class="modal" style="padding:16px;width:min(560px,94vw)">
|
||||
<h3 id="method-ask-title" style="margin:0 0 6px">Connect {{methodAsk.provider.display_name}}</h3>
|
||||
<p class="sub" style="margin:0">Choose how this account should connect. You can add the other method separately later.</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="copyTool=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { copyTool=null }" @click.self="copyTool=null">
|
||||
<div class="modal"><div class="hd"><b>Use “{{copyTool.name}}”</b><button class="btn sm ico" @click="copyTool=null" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<div class="tabs"><button v-for="t in snippetTabs" :key="t" :class="{active:snippetTab===t}" @click="snippetTab=t">{{t}}</button></div>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="newTool=false;toolErr=''">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { newTool=false;toolErr='' }" @click.self="newTool=false;toolErr=''">
|
||||
<div class="modal" style="width:min(620px,95vw)"><div class="hd"><b>{{(tForm.id?'Edit ':'Add ')+(tForm.mode==='cli'?'CLI':'endpoint')}}</b><button class="btn sm" @click="newTool=false;toolErr=''" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<template v-if="tForm.mode!=='cli'">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="newSkill=false">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { newSkill=false }" @click.self="newSkill=false">
|
||||
<div class="modal" style="width:min(680px,95vw)"><div class="hd"><b>Add a skill</b><button class="btn sm ico" @click="newSkill=false" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<template v-if="skillMode==='folder'">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="fish-voice-dialog-title" @click.self="closeFishVoiceDialog">
|
||||
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="fish-voice-dialog-title" v-dialog="closeFishVoiceDialog" @click.self="closeFishVoiceDialog">
|
||||
<div class="modal" style="width:min(470px,94vw);padding:18px 20px">
|
||||
<div class="hd"><b id="fish-voice-dialog-title">{{fishVoiceDialog.action==='rename'?'Rename voice':'Delete voice'}}</b><button class="btn sm ico" @click="closeFishVoiceDialog" aria-label="Close">✕</button></div>
|
||||
<template v-if="fishVoiceDialog.action!=='delete'">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="vendorAsk=false">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { vendorAsk=false }" @click.self="vendorAsk=false">
|
||||
<div class="modal" style="width:min(620px,95vw)"><div class="hd"><b>List your API in this catalog</b><button class="btn sm ico" @click="vendorAsk=false" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<p class="explain">Sell an API? This is an <b>instruction for your coding agent</b>, not for you — paste it into Claude Code, Codex or any coding agent. The agent reads the hosted instructions, prepares the listing files, and opens a pull request on the treg repo — with your contact email so we can arrange live verification.</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="viewRecipe=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { viewRecipe=null }" @click.self="viewRecipe=null">
|
||||
<div class="modal" style="width:min(760px,95vw)"><div class="hd"><b>{{viewRecipe.name}} <span class="sub" style="font-weight:400">· SKILL.md</span></b><button class="btn sm ico" @click="viewRecipe=null" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<textarea v-if="canRegister" v-model="viewRecipe.recipe" spellcheck="false" style="width:100%;height:52vh;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:12px;font-family:var(--mono);font-size:12.5px;line-height:1.5;resize:vertical"></textarea>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="reqAsk=false;reqErr=''">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { reqAsk=false;reqErr='' }" @click.self="reqAsk=false;reqErr=''">
|
||||
<div class="modal" style="width:min(520px,95vw)"><div class="hd"><b>Request a tool</b><button class="btn sm ico" @click="reqAsk=false;reqErr=''" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<template v-if="!reqDone">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="resPick=null">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { resPick=null }" @click.self="resPick=null">
|
||||
<div class="modal" style="padding:16px">
|
||||
<h3 style="margin:0 0 10px">Choose {{article(resPick.label)}} {{resPick.label}}</h3>
|
||||
<p class="sub" style="margin:0 0 10px">The {{resPick.label}} your agent uses by default.
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="tryTool=null" style="place-items:stretch;justify-items:end">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { tryTool=null }" @click.self="tryTool=null" style="place-items:stretch;justify-items:end">
|
||||
<div class="drawer"><div class="hd" style="padding:15px 18px;border-bottom:1px solid var(--line)"><b>Use “{{tryTool.name}}”</b><button class="btn sm" @click="tryTool=null" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px;overflow:auto">
|
||||
<div v-if="canCall(tryTool) && canRun(tryTool)" class="exrow" style="margin-bottom:12px">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="share.on=false">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { share.on=false }" @click.self="share.on=false">
|
||||
<div class="modal" style="width:min(560px,95vw)"><div class="hd"><b>Share “{{detail.name}}”</b><button class="btn sm ico" @click="share.on=false" aria-label="Close">✕</button></div>
|
||||
<div style="padding:16px 18px">
|
||||
<template v-if="share.sent">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="topupOpen=false">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="() => { topupOpen=false }" @click.self="topupOpen=false">
|
||||
<div class="modal" style="padding:18px 20px;width:min(620px,94vw)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h3 style="margin:0">Top up credits</h3>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" @click.self="closeEpTry" style="place-items:stretch;justify-items:end">
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog="closeEpTry" @click.self="closeEpTry" style="place-items:stretch;justify-items:end">
|
||||
<div class="drawer"><div class="hd" style="padding:15px 18px;border-bottom:1px solid var(--line)"><b>Try “{{epTry.id}}”</b><button class="btn sm" @click="closeEpTry" aria-label="Close">✕</button></div>
|
||||
<div class="bd" style="padding:16px 18px;overflow:auto">
|
||||
<p class="explain"><span class="mono">{{epTry.method||'GET'}} {{epTryDisplayPath}}</span><br>{{epTry.summary}}</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ export default { components: { BrandMark }, setup: useDashboard }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" role="dialog" aria-modal="true" >
|
||||
<div class="scrim" role="dialog" aria-modal="true" v-dialog>
|
||||
<div class="modal" :style="{width: welcome.step===0?'min(470px,94vw)':(welcome.step===3?'min(680px,94vw)':'min(560px,94vw)')}">
|
||||
<div style="padding:26px 26px 22px">
|
||||
<template v-if="welcome.step===0">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
/**
|
||||
* `v-dialog` on the element that carries role="dialog": one keyboard contract for every modal and
|
||||
* drawer instead of per-dialog code. While it is mounted, the dialog
|
||||
* - takes focus (its first form field, else the dialog itself),
|
||||
* - keeps Tab and Shift+Tab inside itself,
|
||||
* - closes on Escape when given a close function (a required decision passes none),
|
||||
* - and, once unmounted, hands focus back to the control that opened it.
|
||||
* Open dialogs form a stack, so only the topmost one answers the keyboard.
|
||||
*/
|
||||
type Close = (() => unknown) | null | undefined
|
||||
interface Entry { el: HTMLElement, close: Close, opener: Element | null, fallback: Element | null }
|
||||
|
||||
const stack: Entry[] = []
|
||||
const FOCUSABLE = 'a[href],area[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),'
|
||||
+ 'select:not([disabled]),textarea:not([disabled]),summary,[contenteditable="true"],[tabindex]:not([tabindex="-1"])'
|
||||
const FIELD = 'input:not([disabled]):not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),select:not([disabled]),textarea:not([disabled])'
|
||||
|
||||
// checkVisibility also sees the content of a closed <details>, which keeps its boxes but takes no focus.
|
||||
function visible(el: Element) {
|
||||
return typeof el.checkVisibility === 'function' ? el.checkVisibility({ visibilityProperty: true }) : el.getClientRects().length > 0
|
||||
}
|
||||
function focusables(root: HTMLElement) { return [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(visible) }
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const top = stack[stack.length - 1]
|
||||
if (!top) return
|
||||
if (event.key === 'Escape') {
|
||||
if (!top.close) return
|
||||
// Capture phase: the page's own Escape handling (menus) must not also run for this press.
|
||||
event.preventDefault(); event.stopImmediatePropagation()
|
||||
top.close()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const items = focusables(top.el)
|
||||
const active = document.activeElement
|
||||
if (!items.length) { event.preventDefault(); top.el.focus(); return }
|
||||
const first = items[0]!, last = items[items.length - 1]!
|
||||
if (!active || !top.el.contains(active) || active === top.el) {
|
||||
event.preventDefault(); (event.shiftKey ? last : first).focus()
|
||||
} else if (event.shiftKey && active === first) {
|
||||
event.preventDefault(); last.focus()
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault(); first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const entries = new WeakMap<HTMLElement, Entry>()
|
||||
|
||||
export const vDialog: Directive<HTMLElement, Close> = {
|
||||
mounted(el, binding) {
|
||||
const active = document.activeElement
|
||||
const owner = stack.find(entry => active && entry.el.contains(active))
|
||||
const entry: Entry = { el, close: binding.value, opener: active, fallback: owner ? owner.opener : null }
|
||||
entries.set(el, entry)
|
||||
stack.push(entry)
|
||||
if (stack.length === 1) document.addEventListener('keydown', onKeydown, true)
|
||||
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1')
|
||||
// A dialog that already placed focus inside itself (an explicit ref focus) keeps it.
|
||||
if (active && el.contains(active)) return
|
||||
const field = [...el.querySelectorAll<HTMLElement>(FIELD)].find(visible)
|
||||
;(field || el).focus({ preventScroll: !field })
|
||||
},
|
||||
updated(el, binding) {
|
||||
const entry = entries.get(el)
|
||||
if (entry) entry.close = binding.value
|
||||
},
|
||||
unmounted(el) {
|
||||
const entry = entries.get(el)
|
||||
if (!entry) return
|
||||
entries.delete(el)
|
||||
stack.splice(stack.indexOf(entry), 1)
|
||||
if (!stack.length) document.removeEventListener('keydown', onKeydown, true)
|
||||
// Focus moved on to something still on the page (another dialog, a page control): leave it.
|
||||
const active = document.activeElement
|
||||
if (active && active !== document.body && active.isConnected && !el.contains(active)) return
|
||||
const target = [entry.opener, entry.fallback].find(item => item instanceof HTMLElement && item.isConnected && item !== document.body)
|
||||
if (target) (target as HTMLElement).focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
interface GlobalDirectives { vDialog: typeof vDialog }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { vDialog } from './dialogs/dialog'
|
||||
// The two brand faces, from pinned npm packages (OFL-1.1) and bundled same-origin: Geist Pixel for
|
||||
// page titles, DM Mono for figures and code. Body text uses the system font (DESIGN.md).
|
||||
import '@fontsource/geist-pixel/latin-400.css'
|
||||
@@ -10,6 +11,7 @@ import './styles/base.css'
|
||||
import '../../src/treg/web/media/redesign/dashboard.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.directive('dialog', vDialog)
|
||||
const setup = (window as unknown as { TregAgentSetup: Record<string, object> }).TregAgentSetup
|
||||
app.component('TregTryItOut', setup.TryItOut!)
|
||||
app.component('TregAgentPicker', setup.AgentPicker!)
|
||||
|
||||
@@ -43,10 +43,7 @@ export default {
|
||||
computed: {...resourcesComputed, ...billingComputed, ...catalogComputed, ...sessionComputed, ...agentsComputed, ...onboardingComputed, ...detailsComputed, ...findComputed},
|
||||
methods: {...resources, setElement(name, element) { this.elements[name] = element }, ...session, ...team, ...keys, ...agents, ...projects, ...governance, ...activity, ...billing, ...referrals, ...secrets, ...tools, ...skills, ...format, ...onboarding, ...analytics, ...help, ...connections, ...sharing, ...navigation, ...catalog, ...details, ...admin, ...snippets, ...tryTool, ...find, ...lifecycle, ...hub},
|
||||
watch:{
|
||||
// a11y (WCAG 2.4.3): when a dialog/drawer opens, move focus INTO it (was left on the trigger)
|
||||
newTool(v){ this.focusOverlay(v); }, newSkill(v){ this.focusOverlay(v); }, newOrg(v){ this.focusOverlay(v); },
|
||||
showJoin(v){ this.focusOverlay(v); }, addOrg(v){ this.focusOverlay(v); }, copyTool(v){ this.focusOverlay(v); },
|
||||
tryTool(v){ this.focusOverlay(v); }, 'welcome.on'(v){ this.focusOverlay(v); }, reqAsk(v){ this.focusOverlay(v); },
|
||||
// Dialog focus (in on open, trapped, back to the trigger on close) and Escape: v-dialog (dialogs/dialog.ts)
|
||||
'welcome.agent'(v){ storageSet('treg-agent', v); }, // see _restoreAgent
|
||||
activeOrgId(){ this.resetRenameForm(); }, // team switch or first load: prefill the rename form
|
||||
// Editing the box after a find starts a new question: the answer to the old one goes away
|
||||
|
||||
@@ -16,5 +16,7 @@ placeOrgMenu(){ // the sidebar is a scroll container that clips overflow, so th
|
||||
this.orgMenuStyle={position:'fixed', top:(r.bottom+6)+'px', left:r.left+'px'};
|
||||
},
|
||||
toggleOrgMenu(){ this.orgMenu=!this.orgMenu; if(this.orgMenu) this.placeOrgMenu(); },
|
||||
closeOverlays(){ this.newTool=false; this.newSkill=false; this.showJoin=false; this.newOrg=false; this.addOrg=false; this.tryTool=null; this.copyTool=null; this.orgMenu=false; this.keyMenu=null; this.keyConfirm=null; this.share.on=false; this.methodAsk=null; if(!this.fishVoiceDialog||!this.fishVoiceDialog.busy)this.fishVoiceDialog=null; }
|
||||
// Escape on the page's menus. An open dialog takes Escape first and closes itself (v-dialog), so
|
||||
// this only runs when none is open.
|
||||
closeOverlays(){ this.orgMenu=false; this.keyMenu=null; }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { requestJson } from '../api'
|
||||
import { LS } from './constants.js'
|
||||
|
||||
export default {
|
||||
focusOverlay(open){ if(!open) return; this.$nextTick(()=>{ const el=document.querySelector('.scrim input,.scrim textarea,.scrim button,.drawer input,.drawer button'); el&&el.focus(); }); },
|
||||
servedOn(tier){ return ({anonymous:'public provider route (no key)',platform:'treg key',credential:'your key',tool:'your registered tool','platform-overflow':'treg overflow'})[tier]||tier; },
|
||||
headers(tok){
|
||||
const h={'ngrok-skip-browser-warning':'1'};
|
||||
|
||||
@@ -359,6 +359,8 @@
|
||||
.method-copy{min-width:0;flex:1}.method-title{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
||||
.method-copy .sub{display:block;margin:4px 0 0;line-height:1.5}
|
||||
.scrim{position:fixed;inset:0;background:rgba(20,16,10,.55);display:grid;place-items:center;z-index:50}
|
||||
/* v-dialog focuses the dialog itself when it has no field to start in; that is not a control. */
|
||||
[role="dialog"][tabindex="-1"]:focus{outline:none}
|
||||
/* Inline spinner for waits on a live upstream (resource discovery), so a slow round-trip reads
|
||||
as "working" rather than "broken". */
|
||||
.spin{display:inline-block;width:11px;height:11px;margin-right:7px;vertical-align:-1px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:treg-spin .7s linear infinite}
|
||||
|
||||
Reference in New Issue
Block a user