Files
Danny White e7796315d2 fix sheets stealing focus (#48521)
## What kind of change does this PR introduce?

Bug fix. Resolves DEPR-539.

## What is the current behavior?

When a focused child unmounts, Radix can move focus to the Sheet wrapper
and break the expected tab order. Several callsites suppress the
wrapper's tabindex individually.

## What is the new behavior?

Sheet still focuses its first interactive child when opened, but the
wrapper itself is no longer focusable by default. Callers can opt in
with an explicit `tabIndex` when needed.

## Additional context

### Testing

Compare this Studio experience on both this branch and `master`:

1. Open any project with an Edge Function.
2. Go to **Edge Functions**, open the function, then click **Test**.
3. Under **Headers**, click **Add Headers**. Click the first header key
input, then Tab slowly through the header inputs and remove buttons.

On `master`, focus can jump to the whole Sheet. On this branch, focus
stays on the controls in order.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved keyboard focus behavior across sheets and panels.
* Sheets now focus the first available interactive element when opened,
without adding unnecessary focus targets.
* Preserved support for programmatic focus and prevented focus from
unexpectedly moving to the sheet when focused content is removed.
* Updated authentication, integrations, connection, logging, storage,
and other sheet interfaces consistently.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 10:01:47 +10:00

208 lines
7.5 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import Link from 'next/link'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useState } from 'react'
import { Button, Sheet, SheetContent } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
import { useAvailableIntegrations } from '../Landing/useAvailableIntegrations'
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
import { CreateWrapperSheet } from './CreateWrapperSheet'
import { WRAPPERS } from './Wrappers.constants'
import { WrapperTable } from './WrapperTable'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils'
import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
const WrapperOverviewContent = () => {
const { id } = useParams()
const wrapperMeta = WRAPPERS.find((w) => w.name === id)
const [isDirty, setIsDirty] = useState(false)
const [createWrapperShown, setCreateWrapperShown] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose: () => {
setCreateWrapperShown(false)
setIsDirty(false)
},
})
// [Joshen] Opting to declare custom wrapper sheets here instead of within Wrappers.constants.ts
// as we'll easily run into circular dependencies doing so unfortunately
const CreateWrapperSheetComponent = !wrapperMeta
? null
: wrapperMeta.customComponent
? wrapperMeta.name === 'iceberg_wrapper'
? CreateIcebergWrapperSheet
: null
: CreateWrapperSheet
return (
<>
<div className="flex flex-col gap-y-5">
<p>Recent wrappers</p>
<WrapperTable />
</div>
{!!CreateWrapperSheetComponent && !!wrapperMeta && (
<Sheet open={!!createWrapperShown} onOpenChange={handleOpenChange}>
<SheetContent size="lg">
<CreateWrapperSheetComponent
wrapperMeta={wrapperMeta}
onDirty={setIsDirty}
onClose={() => setCreateWrapperShown(false)}
onCloseWithConfirmation={confirmOnClose}
/>
</SheetContent>
</Sheet>
)}
<DiscardChangesConfirmationDialog {...modalProps} />
</>
)
}
const AddNewWrapperCTA = () => {
const { id } = useParams()
const { data: project } = useSelectedProjectQuery()
const [, setCreateWrapperShown] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const { can: canCreateWrapper } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'wrappers'
)
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrapperMeta = WRAPPERS.find((w) => w.name === id)
const wrappersExtension = data?.find((ext) => ext.name === 'wrappers')
const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version
const hasRequiredVersion =
(wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
// [Joshen] Default version is what's on the DB, so if the installed version is already the default version
// but still doesnt meet the minimum extension version, then DB upgrade is required
const databaseNeedsUpgrading =
wrappersExtension?.installed_version === wrappersExtension?.default_version
if (!!wrapperMeta && isWrappersExtensionInstalled && !hasRequiredVersion) {
return (
<Admonition type="warning" title="Your extension version is outdated for this wrapper">
<div className="flex flex-col gap-y-2 [&>p]:mb-0!">
<p>
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
{wrappersExtension?.installed_version} installed. Please{' '}
{databaseNeedsUpgrading && 'upgrade your database then '}update the extension by
disabling and enabling the <code className="text-code-inline">wrappers</code> extension
to create this wrapper.
</p>
<p className="text-warning">
Warning: Before reinstalling the wrapper extension, you must first remove all existing
wrappers. Afterward, you can recreate the wrappers.
</p>
</div>
<Button asChild variant="default" className="w-min mt-3">
<Link
href={
databaseNeedsUpgrading
? getServiceVersionsPath(project?.ref)
: `/project/${project?.ref}/database/extensions?filter=wrappers`
}
>
{databaseNeedsUpgrading ? 'Upgrade database' : 'View wrappers extension'}
</Link>
</Button>
</Admonition>
)
}
return (
<div className="py-3 px-5 border rounded-md">
<ButtonTooltip
variant="default"
onClick={() => setCreateWrapperShown(true)}
disabled={!canCreateWrapper}
tooltip={{
content: {
text: !canCreateWrapper
? 'You need additional permissions to create a foreign data wrapper'
: undefined,
},
}}
>
Add new wrapper
</ButtonTooltip>
</div>
)
}
export const WrapperContent = () => {
const { id } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: integrations = [] } = useAvailableIntegrations()
const integration = integrations.find((i) => i.id === id)
const wrapperMeta = WRAPPERS.find((w) => w.name === id)
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const installableExtensions = (extensions ?? []).filter((ext) =>
(integration?.requiredExtensions ?? []).includes(ext.name)
)
const isInstalled = installableExtensions.every((x) => x.installed_version)
if (!wrapperMeta) {
return (
<ScaffoldContainer>
<ScaffoldSection isFullWidth>
<p className="text-sm text-foreground-light">Unsupported integration type</p>
</ScaffoldSection>
</ScaffoldContainer>
)
}
return (
<>
<RequiredExtensionsSection />
<AddNewWrapperCTA />
{isInstalled && <WrapperOverviewContent />}
</>
)
}
export const WrapperOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
return (
<IntegrationOverviewTab actions={<AddNewWrapperCTA />}>
<div className="mx-10">
<WrapperOverviewContent />
</div>
</IntegrationOverviewTab>
)
}