mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore(studio): session expired dialog (#43122)
## What kind of change does this PR introduce? UI improvement ## What is the current behavior? The session expired dialog is quite hard to parse when, for most people, the ask is simple. ## What is the new behavior? Improved session expired dialog: - Refactored to use AlertDialog - Clarified copywriting - Uses the new AlertCollapsible to hide all the complicated debugging steps under a toggle - This component is documented in the design-system | Before | After | | --- | --- | | <img width="1024" height="563" alt="Supabase-B1728A05-DDD2-4A50-AED4-D62EAA2E7D7C" src="https://github.com/user-attachments/assets/771f85d8-21ea-42b5-99f5-b9b05f5617dd" /> | <img width="1024" height="563" alt="Storage Supabase" src="https://github.com/user-attachments/assets/b2fcab68-fb29-4cb9-bd42-aecc57b9fa32" /> | | <img width="1024" height="563" alt="Supabase-B1728A05-DDD2-4A50-AED4-D62EAA2E7D7C" src="https://github.com/user-attachments/assets/771f85d8-21ea-42b5-99f5-b9b05f5617dd" /> | <img width="1024" height="563" alt="Storage Supabase" src="https://github.com/user-attachments/assets/babd3711-5fdf-43b9-be06-d96268a191fd" /> | ## To test In apps/studio/hooks/misc/withAuth.tsx: ```diff - const [isSessionTimeoutModalOpen, setIsSessionTimeoutModalOpen] = useState(false) + const [isSessionTimeoutModalOpen, setIsSessionTimeoutModalOpen] = useState(true) // Mocked as true for UI testing ``` --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
4f26af6259
commit
22ff9b2d81
12 files changed
+297
-159
No files matched your search
@@ -148,6 +148,17 @@ export const Index: Record<string, any> = {
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"alert-collapsible-demo": {
|
||||
name: "alert-collapsible-demo",
|
||||
type: "components:example",
|
||||
registryDependencies: ["alert","collapsible","button"],
|
||||
component: React.lazy(() => import("@/registry/default/example/alert-collapsible-demo")),
|
||||
source: "",
|
||||
files: ["registry/default/example/alert-collapsible-demo.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"alert-dialog-demo": {
|
||||
name: "alert-dialog-demo",
|
||||
type: "components:example",
|
||||
|
||||
@@ -1,67 +1,37 @@
|
||||
---
|
||||
title: Collapsible
|
||||
description: An interactive component which expands/collapses a panel.
|
||||
title: Alert Collapsible
|
||||
description: An alert-styled block with a collapsible content section, toggled by an icon button.
|
||||
component: true
|
||||
featured: true
|
||||
links:
|
||||
doc: https://www.radix-ui.com/docs/primitives/components/collapsible
|
||||
api: https://www.radix-ui.com/docs/primitives/components/collapsible#api-reference
|
||||
source:
|
||||
radix: true
|
||||
shadcn: true
|
||||
---
|
||||
|
||||
<ComponentPreview name="collapsible-demo" peekCode wide />
|
||||
|
||||
## Installation
|
||||
|
||||
<Tabs defaultValue="cli">
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">CLI</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn-ui@latest add collapsible
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>Install the following dependencies:</Step>
|
||||
|
||||
```bash
|
||||
npm install @radix-ui/react-collapsible
|
||||
```
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource name="collapsible" />
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
<ComponentPreview name="alert-collapsible-demo" peekCode wide />
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { AlertCollapsible } from 'ui'
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>Can I use this in my project?</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
Yes. Free to use for personal and commercial projects. No attribution required.
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<AlertCollapsible trigger="Need help?">
|
||||
<p>Content revealed when expanded.</p>
|
||||
</AlertCollapsible>
|
||||
```
|
||||
|
||||
### Default open
|
||||
|
||||
```tsx
|
||||
<AlertCollapsible trigger="Details" defaultOpen>
|
||||
<p>This section is expanded by default.</p>
|
||||
</AlertCollapsible>
|
||||
```
|
||||
|
||||
### Variants
|
||||
|
||||
Supports the same `variant` prop as `Alert`: `default`, `destructive`, and `warning`.
|
||||
|
||||
```tsx
|
||||
<AlertCollapsible trigger="Warning" variant="warning">
|
||||
<p>Something needs attention.</p>
|
||||
</AlertCollapsible>
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AlertCollapsible } from 'ui'
|
||||
|
||||
export default function AlertCollapsibleDemo() {
|
||||
return (
|
||||
<AlertCollapsible trigger="Need help?">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Try a different browser or disable extensions that block network requests.
|
||||
</p>
|
||||
</AlertCollapsible>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,12 @@ export const examples: Registry = [
|
||||
registryDependencies: ['alert'],
|
||||
files: ['example/alert-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'alert-collapsible-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['alert', 'collapsible', 'button'],
|
||||
files: ['example/alert-collapsible-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'alert-dialog-demo',
|
||||
type: 'components:example',
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink'
|
||||
import { toast } from 'sonner'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import {
|
||||
AlertCollapsible,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
} from 'ui'
|
||||
import { SupportLink } from '../Support/SupportLink'
|
||||
|
||||
interface SessionTimeoutModalProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
redirectToSignIn: () => void
|
||||
/** Optional context so the support form can pre-populate when opened from this dialog */
|
||||
supportContext?: { projectRef?: string; orgSlug?: string }
|
||||
}
|
||||
|
||||
export const SessionTimeoutModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
redirectToSignIn,
|
||||
supportContext,
|
||||
}: SessionTimeoutModalProps) => {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -23,60 +38,70 @@ export const SessionTimeoutModal = ({
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleClearStorage = () => {
|
||||
try {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
} catch (e) {
|
||||
toast.error('Failed to clear browser storage')
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
visible={visible}
|
||||
title="Session timed out"
|
||||
confirmLabel="Sign in again"
|
||||
onCancel={onClose}
|
||||
onConfirm={redirectToSignIn}
|
||||
alert={{
|
||||
base: { variant: 'warning' },
|
||||
title: 'Your session has timed out',
|
||||
description:
|
||||
'Please try signing in again. If you are not able to sign in again, please contact Support.',
|
||||
<AlertDialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 text-sm text-foreground-light">
|
||||
<ul className="list-disc pl-1.5 list-inside space-y-1 text-sm text-foreground-light">
|
||||
<li>Try with a different browser</li>
|
||||
<li>Disable browser extensions that block network requests</li>
|
||||
<li>
|
||||
<button
|
||||
title="Clear storage and reload"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
try {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
} catch (e) {
|
||||
toast.error('Failed to clear browser storage')
|
||||
}
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
Clear your browser storage
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
If none of these steps work, please{' '}
|
||||
<SupportLink
|
||||
className={InlineLinkClassName}
|
||||
queryParams={{ subject: 'Session timed out' }}
|
||||
>
|
||||
Contact support
|
||||
</SupportLink>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
Consider{' '}
|
||||
<InlineLink href="https://github.com/orgs/supabase/discussions/36540">
|
||||
generating a HAR file
|
||||
</InlineLink>{' '}
|
||||
from your session to help Support pinpoint the issue.
|
||||
</p>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
<AlertDialogContent size="small">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Session expired</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p>Please sign in again to continue.</p>
|
||||
<AlertCollapsible trigger="Having trouble?">
|
||||
<div className="space-y-3 text-foreground-light">
|
||||
<p>
|
||||
Try a different browser or disable extensions that block network requests. If
|
||||
the problem persists:
|
||||
</p>
|
||||
<Button type="default" size="tiny" onClick={handleClearStorage}>
|
||||
Clear site data and reload
|
||||
</Button>
|
||||
<p>
|
||||
Still stuck?{' '}
|
||||
<SupportLink
|
||||
className={InlineLinkClassName}
|
||||
queryParams={{
|
||||
subject: 'Session expired',
|
||||
category: SupportCategories.LOGIN_ISSUES,
|
||||
...(supportContext?.projectRef && {
|
||||
projectRef: supportContext.projectRef,
|
||||
}),
|
||||
...(supportContext?.orgSlug && { orgSlug: supportContext.orgSlug }),
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
Contact support
|
||||
</SupportLink>{' '}
|
||||
and include a{' '}
|
||||
<InlineLink href="https://github.com/orgs/supabase/discussions/36540">
|
||||
HAR file
|
||||
</InlineLink>{' '}
|
||||
from your session to help us investigate.
|
||||
</p>
|
||||
</div>
|
||||
</AlertCollapsible>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Close</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={redirectToSignIn}>Sign in again</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
// End of third-party imports
|
||||
|
||||
import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { compact } from 'lodash'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { File, FileCode, Plus, X } from 'lucide-react'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -10,12 +16,8 @@ import {
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
// End of third-party imports
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { cn } from 'ui'
|
||||
import { createSupportStorageClient } from './support-storage-client'
|
||||
|
||||
const MAX_ATTACHMENTS = 5
|
||||
@@ -26,7 +28,7 @@ const uploadAttachments = async ({ userId, files }: { userId: string; files: Fil
|
||||
const filesToUpload = Array.from(files)
|
||||
const uploadedFiles = await Promise.all(
|
||||
filesToUpload.map(async (file) => {
|
||||
const suffix = file.type.split('/')[1]
|
||||
const suffix = file.name.endsWith('.har') ? 'har' : file.type.split('/')[1]
|
||||
const prefix = `${userId}/${uuidv4()}.${suffix}`
|
||||
const options = { cacheControl: '3600' }
|
||||
|
||||
@@ -87,7 +89,13 @@ export function useAttachmentUpload() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!uploadedFiles) return
|
||||
const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file))
|
||||
const objectUrls = uploadedFiles.map((file) => {
|
||||
if (file.name.endsWith('.har')) {
|
||||
return file.name
|
||||
} else {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
})
|
||||
setUploadedDataUrls(objectUrls)
|
||||
|
||||
return () => {
|
||||
@@ -158,10 +166,12 @@ export function AttachmentUploadDisplay({
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<p className="text-sm text-foreground-light">Attachments</p>
|
||||
<p className="text-sm text-foreground">Attachments</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that you're
|
||||
facing
|
||||
Optionally upload up to {MAX_ATTACHMENTS} relevant images or{' '}
|
||||
<InlineLink href="https://github.com/orgs/supabase/discussions/36540">
|
||||
HAR files
|
||||
</InlineLink>
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -169,29 +179,64 @@ export function AttachmentUploadDisplay({
|
||||
type="file"
|
||||
ref={uploadButtonRef}
|
||||
className="hidden"
|
||||
accept="image/png, image/jpeg"
|
||||
accept="image/png, image/jpeg, .har"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{uploadedDataUrls.map((url, idx) => (
|
||||
<div
|
||||
key={url}
|
||||
style={{ backgroundImage: `url("${url}")` }}
|
||||
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove attachment"
|
||||
className={cn(
|
||||
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
|
||||
'absolute -top-1 -right-1 cursor-pointer'
|
||||
)}
|
||||
onClick={() => removeFileUpload(idx)}
|
||||
>
|
||||
<X aria-hidden="true" size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{uploadedDataUrls.map((url, idx) => {
|
||||
if (url.endsWith('.har')) {
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
className="border relative h-14 w-14 rounded flex items-center justify-center"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-default" onClick={(e) => e.preventDefault()}>
|
||||
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||
<FileCode className="text-foreground-light" size={16} />
|
||||
<p className="text-[10px] font-mono text-foreground-light tracking-wide leading-none">
|
||||
HAR
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{url}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove attachment"
|
||||
className={cn(
|
||||
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
|
||||
'absolute -top-1 -right-1 cursor-pointer'
|
||||
)}
|
||||
onClick={() => removeFileUpload(idx)}
|
||||
>
|
||||
<X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
style={{ backgroundImage: `url("${url}")` }}
|
||||
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove attachment"
|
||||
className={cn(
|
||||
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
|
||||
'absolute -top-1 -right-1 cursor-pointer'
|
||||
)}
|
||||
onClick={() => removeFileUpload(idx)}
|
||||
>
|
||||
<X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{!isFull && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
// End of third-party imports
|
||||
|
||||
import {
|
||||
DocsSearchResultType as PageType,
|
||||
type DocsSearchResult as Page,
|
||||
type DocsSearchResultSection as PageSection,
|
||||
} from 'common'
|
||||
import { getProjectDetail } from 'data/projects/project-detail-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { partition } from 'lodash'
|
||||
import { Book, Github, Hash, MessageSquare } from 'lucide-react'
|
||||
import {
|
||||
createLoader,
|
||||
createParser,
|
||||
createSerializer,
|
||||
type inferParserType,
|
||||
parseAsString,
|
||||
type inferParserType,
|
||||
type UseQueryStatesKeysMap,
|
||||
} from 'nuqs'
|
||||
// End of third-party imports
|
||||
|
||||
import {
|
||||
type DocsSearchResult as Page,
|
||||
type DocsSearchResultSection as PageSection,
|
||||
DocsSearchResultType as PageType,
|
||||
} from 'common'
|
||||
import { getProjectDetail } from 'data/projects/project-detail-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import type { Organization } from 'types'
|
||||
|
||||
import { CATEGORY_OPTIONS } from './Support.constants'
|
||||
|
||||
export const NO_PROJECT_MARKER = 'no-project'
|
||||
@@ -32,10 +34,13 @@ export const formatMessage = ({
|
||||
attachments?: Array<string>
|
||||
error: string | null | undefined
|
||||
}) => {
|
||||
const [harFiles, images] = partition(attachments, (x) => x.split('?token')[0].endsWith('.har'))
|
||||
const errorString = error != null ? `\n\nError: ${error}` : ''
|
||||
const attachmentsString =
|
||||
attachments.length > 0 ? `\n\nAttachments:\n${attachments.join('\n')}` : ''
|
||||
return `${message}${errorString}${attachmentsString}`
|
||||
|
||||
const imagesString = images.length > 0 ? `\n\nImage Attachments:\n${images.join('\n\n')}` : ''
|
||||
const harFilesString = harFiles.length > 0 ? `\n\nHAR Files:\n${harFiles.join('\n\n')}` : ''
|
||||
|
||||
return `${message}${errorString}${imagesString}${harFilesString}`
|
||||
}
|
||||
|
||||
export const formatStudioVersion = (commit: { commitSha: string; commitTime: string }): string => {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { ComponentType, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useAuth } from 'common'
|
||||
import { SessionTimeoutModal } from 'components/interfaces/SignIn/SessionTimeoutModal'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
import { useAuthenticatorAssuranceLevelQuery } from 'data/profile/mfa-authenticator-assurance-level-query'
|
||||
import { useSignOut } from 'lib/auth'
|
||||
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ComponentType, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { isNextPageWithLayout, type NextPageWithLayout } from 'types'
|
||||
|
||||
const MAX_TIMEOUT = 10000 // 10 seconds
|
||||
@@ -125,12 +124,23 @@ export function withAuth<T>(
|
||||
|
||||
const InnerComponent = WrappedComponent as any
|
||||
|
||||
const supportContext =
|
||||
typeof router.query.ref === 'string' && router.pathname.startsWith('/project/')
|
||||
? {
|
||||
projectRef: router.query.ref,
|
||||
...(typeof router.query.organizationSlug === 'string' && {
|
||||
orgSlug: router.query.organizationSlug,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionTimeoutModal
|
||||
visible={isSessionTimeoutModalOpen}
|
||||
onClose={() => setIsSessionTimeoutModalOpen(false)}
|
||||
redirectToSignIn={redirectToSignIn}
|
||||
supportContext={supportContext}
|
||||
/>
|
||||
<InnerComponent {...props} />
|
||||
</>
|
||||
|
||||
@@ -127,6 +127,8 @@ export {
|
||||
AlertDescription as AlertDescription_Shadcn_,
|
||||
} from './src/components/shadcn/ui/alert'
|
||||
|
||||
export * from './src/components/AlertCollapsible'
|
||||
|
||||
export {
|
||||
Field as Field_Shadcn_,
|
||||
FieldContent as FieldContent_Shadcn_,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { type VariantProps } from 'class-variance-authority'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../../lib/utils/cn'
|
||||
import { Button } from '../Button'
|
||||
import { alertVariants } from '../shadcn/ui/alert'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../shadcn/ui/collapsible'
|
||||
|
||||
export interface AlertCollapsibleProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'>,
|
||||
VariantProps<typeof alertVariants> {
|
||||
trigger: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
const AlertCollapsible = React.forwardRef<HTMLDivElement, AlertCollapsibleProps>(
|
||||
({ className, variant = 'default', trigger, defaultOpen, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
alertVariants({ variant }),
|
||||
// Override default padding to be tighter
|
||||
'p-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Collapsible defaultOpen={defaultOpen}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{trigger}</span>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="outline"
|
||||
size="tiny"
|
||||
className={cn('!px-0 w-[26px]', '[&[data-state=open]_svg]:rotate-180')}
|
||||
aria-label="Toggle"
|
||||
icon={<ChevronDown className="transition-transform duration-200" />}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="pt-3">{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
AlertCollapsible.displayName = 'AlertCollapsible'
|
||||
|
||||
export { AlertCollapsible }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AlertCollapsible } from './AlertCollapsible'
|
||||
export type { AlertCollapsibleProps } from './AlertCollapsible'
|
||||
@@ -10,7 +10,7 @@ export interface CollapsibleProps extends RadixCollapsible.CollapsibleProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `import { Collapsible_shadcn_ } from "ui"` instead
|
||||
* @deprecated Use `import { AlertCollapsible } from "ui"` instead
|
||||
*/
|
||||
export const Collapsible = ({
|
||||
open = undefined,
|
||||
|
||||
Reference in new issue
Block a user