mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## Problem The `<RadioGroupCard>` component has an accessibility issue: duplicate ids on the items. Besides, its usage in the Design System app has additional issue: no label on the group itself when used outside a react-hook-form. Finally, the form example wrap each item in a `FormField` and `FormControl` which is unnecessary and causes another accessibility issue as all items are then injected the same `id` prop. ## Solution - Fix the duplicate ids issue - Fix all design system example - Fix the only wrong usage we have in studio No visual changes ## Notes When used outside a form, I added aria-label attributes on the group and they are announced by Mac Voice Over. However, when used in a form, our components adds a label with the correct for attribute but it seems that Mac Voice Over does not announce it. Not sure about how this should be handled. ## How to test On https://design-system-git-fix-radio-group-card-a11y-supabase.vercel.app/design-system/docs/components/radio-group-card, with Voice Over enabled: - tab to the first radio group, it should announce the value and the label of group itself, _Size_ - tab to the second, same but label is _Theme_ On https://design-system-git-fix-radio-group-card-a11y-supabase.vercel.app/design-system/docs/components/radio-group-card#form (Form example): - select any option and submit - check the correct option is submitted On https://studio-staging-git-fix-radio-group-card-a11y-supabase.vercel.app: - Go to your organization settings, Audit Log Drains, open your devtool network tab - Create a new custom endpoint and select the HTTP version - Check in the network tab that the correct http version is passed <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Accessibility** * Improved labeling for radio card groups, including size, spacing, theme, and webhook version selections. * Radio options now use stable or automatically generated identifiers with reliable label associations. * **Bug Fixes** * Simplified radio option structure in forms for more consistent behavior. * Improved ID handling across radio card, stacked, and large radio options. * Updated the themed radio card example to use the dark theme by default. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
362 lines
16 KiB
TypeScript
362 lines
16 KiB
TypeScript
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
|
import { useParams } from 'common'
|
|
import { CpuIcon, Lock, Microchip } from 'lucide-react'
|
|
import { useMemo } from 'react'
|
|
import { UseFormReturn, useWatch } from 'react-hook-form'
|
|
import {
|
|
cn,
|
|
FormField,
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
RadioGroupCard,
|
|
RadioGroupCardItem,
|
|
Skeleton,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
import { ComputeBadge } from 'ui-patterns/ComputeBadge'
|
|
|
|
import { DiskStorageSchemaType } from '../DiskManagement.schema'
|
|
import { ComputeInstanceAddonVariantId, InfraInstanceSize } from '../DiskManagement.types'
|
|
import {
|
|
ComputeAddonVariant,
|
|
getAvailableComputeOptions,
|
|
mapComputeSizeNameToAddonVariantId,
|
|
} from '../DiskManagement.utils'
|
|
import { BillingChangeBadge } from '../ui/BillingChangeBadge'
|
|
import FormMessage from '../ui/FormMessage'
|
|
import { useShowMicroUpgradeBadge } from './useShowMicroUpgradeBadge'
|
|
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
|
|
import { InlineLink } from '@/components/ui/InlineLink'
|
|
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
|
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
|
|
const SKELETON_PLACEHOLDER_COUNT = 6
|
|
|
|
/**
|
|
* to do: this could be a type from api-types
|
|
*/
|
|
type ComputeSizeFieldProps = {
|
|
form: UseFormReturn<DiskStorageSchemaType>
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function ComputeSizeFieldMeta() {
|
|
return <p>Hardware resources allocated to your Postgres database</p>
|
|
}
|
|
|
|
type ComputeSectionBillingBadgeProps = {
|
|
form: UseFormReturn<DiskStorageSchemaType>
|
|
show: boolean
|
|
beforePrice: number
|
|
afterPrice: number
|
|
}
|
|
|
|
export function ComputeSectionBillingBadge({
|
|
form,
|
|
show,
|
|
beforePrice,
|
|
afterPrice,
|
|
}: ComputeSectionBillingBadgeProps) {
|
|
const computeSize = useWatch({ control: form.control, name: 'computeSize' })
|
|
const { showMicroUpgradeBadge } = useShowMicroUpgradeBadge()
|
|
|
|
return (
|
|
<BillingChangeBadge
|
|
show={show}
|
|
beforePrice={beforePrice}
|
|
afterPrice={afterPrice}
|
|
free={showMicroUpgradeBadge && computeSize === 'ci_micro'}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
|
|
const { ref } = useParams()
|
|
const { data: org } = useSelectedOrganizationQuery()
|
|
const { project, isProjectLoading, isEntitlementLoading, showMicroUpgradeBadge } =
|
|
useShowMicroUpgradeBadge()
|
|
const { isHighAvailability } = useHighAvailability()
|
|
|
|
// High Availability projects run on a fixed compute size during Alpha,
|
|
// so every option other than the project's current size is locked
|
|
const currentComputeVariantId = project?.infra_compute_size
|
|
? mapComputeSizeNameToAddonVariantId(project.infra_compute_size)
|
|
: undefined
|
|
|
|
const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price')
|
|
|
|
const {
|
|
data: addons,
|
|
isPending: isAddonsLoading,
|
|
error: addonsError,
|
|
} = useProjectAddonsQuery({ projectRef: ref })
|
|
|
|
const isLoading = isProjectLoading || isAddonsLoading || isEntitlementLoading
|
|
|
|
const { control, setValue, trigger } = form
|
|
|
|
const availableAddons = useMemo(() => {
|
|
return addons?.available_addons ?? []
|
|
}, [addons])
|
|
const availableOptions = useMemo(() => {
|
|
/**
|
|
* Returns the available compute options for the project
|
|
* Also handles backwards compatibility for older API versions
|
|
* Also handles a case in which Nano is not available from the API
|
|
*/
|
|
return getAvailableComputeOptions(availableAddons, project?.cloud_provider)
|
|
}, [availableAddons, project?.cloud_provider])
|
|
|
|
const subscriptionPitr = addons?.selected_addons.find((addon) => addon.type === 'pitr')
|
|
|
|
const showSkeletons = isLoading
|
|
const showLoadError = !isLoading && !!addonsError
|
|
const showComputeOptions = !isLoading && !addonsError
|
|
|
|
return (
|
|
<FormField
|
|
name="computeSize"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<div className="@container">
|
|
<RadioGroupCard
|
|
value={field.value}
|
|
onValueChange={(value: ComputeInstanceAddonVariantId) => {
|
|
setValue('computeSize', value, {
|
|
shouldDirty: true,
|
|
shouldValidate: true,
|
|
})
|
|
trigger('provisionedIOPS')
|
|
trigger('throughput')
|
|
}}
|
|
disabled={disabled}
|
|
className={cn(
|
|
!addonsError && 'grid grid-cols-2 gap-4 @[680px]:grid-cols-3 @[900px]:grid-cols-4'
|
|
)}
|
|
>
|
|
{showSkeletons &&
|
|
Array(SKELETON_PLACEHOLDER_COUNT)
|
|
.fill(0)
|
|
.map((_, i) => <Skeleton key={i} className="w-full h-[110px] rounded-md" />)}
|
|
{showLoadError && (
|
|
<FormMessage message={'Failed to load Compute size options'} type="error">
|
|
<p>{addonsError?.message}</p>
|
|
</FormMessage>
|
|
)}
|
|
{showComputeOptions && (
|
|
<>
|
|
{availableOptions.map((compute) => {
|
|
const lockedMicroDueToPITR =
|
|
compute.identifier === 'ci_micro' && !!subscriptionPitr
|
|
const lockedNanoDueToPlan =
|
|
org?.plan.id !== 'free' &&
|
|
project?.infra_compute_size !== 'nano' &&
|
|
compute.identifier === 'ci_nano'
|
|
const lockedDueToHighAvailability =
|
|
isHighAvailability && compute.identifier !== currentComputeVariantId
|
|
|
|
const lockedOption =
|
|
lockedNanoDueToPlan || lockedMicroDueToPITR || lockedDueToHighAvailability
|
|
|
|
// Nano on a paid plan is billed at the Micro rate
|
|
const isNanoBilledAsMicro =
|
|
org?.plan.id !== 'free' &&
|
|
project?.infra_compute_size === 'nano' &&
|
|
compute.identifier === 'ci_nano'
|
|
|
|
const price = isNanoBilledAsMicro
|
|
? availableOptions.find(
|
|
(option: ComputeAddonVariant) => option.identifier === 'ci_micro'
|
|
)?.price
|
|
: compute.price
|
|
|
|
const cpuLabel = (() => {
|
|
const cpuCores = compute.meta?.cpu_cores
|
|
if (typeof cpuCores === 'number') {
|
|
return `${cpuCores}-core CPU`
|
|
}
|
|
if (cpuCores) {
|
|
return `${cpuCores} CPU`
|
|
}
|
|
return 'CPU'
|
|
})()
|
|
|
|
return (
|
|
<RadioGroupCardItem
|
|
showIndicator={false}
|
|
id={compute.identifier}
|
|
key={compute.identifier}
|
|
value={compute.identifier}
|
|
className={cn(
|
|
'relative text-sm text-left flex flex-col gap-0 px-0 py-3 group w-full h-[110px]',
|
|
lockedOption && 'opacity-50'
|
|
)}
|
|
disabled={disabled || lockedOption}
|
|
label={
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div>
|
|
{showMicroUpgradeBadge && compute.identifier === 'ci_micro' && (
|
|
<HoverCard openDelay={200}>
|
|
<HoverCardTrigger asChild>
|
|
<div
|
|
className="absolute -top-4 -right-3 text-violet-1100 flex items-center gap-1 bg-surface-75 py-0.5 px-2 rounded-full border border-violet-900 cursor-default"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<span>Free Upgrade</span>
|
|
</div>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="top"
|
|
align="end"
|
|
className="w-72"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<p className="text-sm font-medium text-foreground">
|
|
Upgrade to Micro Compute
|
|
</p>
|
|
<p className="text-sm text-foreground-light">
|
|
This Project is already paying for Micro Compute. You can
|
|
upgrade to Micro Compute at any time when convenient.
|
|
</p>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
)}
|
|
<div className="w-full flex flex-col gap-3 justify-between">
|
|
<div className="relative px-3 opacity-50 group-data-[state=checked]:opacity-100 flex justify-between">
|
|
<ComputeBadge
|
|
className="inline-flex font-semibold"
|
|
infraComputeSize={compute.name as InfraInstanceSize}
|
|
/>
|
|
<div className="flex items-center space-x-1">
|
|
{lockedOption && (
|
|
<div className="bg border rounded-lg h-7 w-7 flex items-center justify-center">
|
|
<Lock size={14} />
|
|
</div>
|
|
)}
|
|
{!lockedOption && showComputePrice && (
|
|
<>
|
|
<span
|
|
className="text-foreground text-sm font-semibold"
|
|
translate="no"
|
|
>
|
|
${price}
|
|
</span>
|
|
<span className="text-foreground-light translate-y-px">
|
|
{' '}
|
|
/{' '}
|
|
{compute.price_interval === 'monthly' ? 'month' : 'hour'}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full">
|
|
<div className="px-3 text-sm flex flex-col gap-1">
|
|
<div className="text-foreground-light flex gap-2 items-center">
|
|
<Microchip
|
|
strokeWidth={1}
|
|
size={14}
|
|
className="text-foreground-lighter"
|
|
/>
|
|
<span>
|
|
{compute.identifier === 'ci_nano' && 'Up to '}
|
|
{compute.meta?.memory_gb ?? 0} GB memory
|
|
</span>
|
|
</div>
|
|
<div className="text-foreground-light flex gap-2 items-center">
|
|
<CpuIcon
|
|
strokeWidth={1}
|
|
size={14}
|
|
className="text-foreground-lighter"
|
|
/>
|
|
<span>{cpuLabel}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TooltipTrigger>
|
|
{lockedDueToHighAvailability && (
|
|
<TooltipContent side="bottom" className="w-64 text-center">
|
|
Compute size can't be changed on High Availability projects during
|
|
Alpha
|
|
</TooltipContent>
|
|
)}
|
|
{!lockedDueToHighAvailability && lockedMicroDueToPITR && (
|
|
<TooltipContent side="bottom" className="w-64 text-center">
|
|
Project has PITR enabled which requires a minimum of Small compute.
|
|
Please{' '}
|
|
<InlineLink href="/project/_/settings/addons?panel=pitr">
|
|
disable PITR
|
|
</InlineLink>{' '}
|
|
first before selecting Micro
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
{!isHighAvailability && (
|
|
<div
|
|
className={cn(
|
|
'relative text-sm text-left flex flex-col gap-0 px-0 py-3 w-full h-[110px]',
|
|
'bg-overlay rounded-md border p-2 hover:border-control-hover'
|
|
)}
|
|
>
|
|
<SupportLink
|
|
queryParams={{
|
|
projectRef: ref,
|
|
category: SupportCategories.SALES_ENQUIRY,
|
|
subject: 'Enquiry about larger instance sizes',
|
|
}}
|
|
>
|
|
<div className="w-full flex flex-col gap-3 justify-between">
|
|
<div className="relative px-3 flex justify-between">
|
|
<ComputeBadge infraComputeSize=">16XL" />
|
|
|
|
<div className="flex items-center space-x-1 opacity-50 ">
|
|
<span className="text-foreground-light text-sm">Contact Us</span>
|
|
</div>
|
|
</div>
|
|
<div className="w-full">
|
|
<div className="px-3 text-sm flex flex-col gap-1">
|
|
<div className="text-foreground-light flex gap-2 items-center">
|
|
<Microchip
|
|
strokeWidth={1}
|
|
size={14}
|
|
className="text-foreground-lighter"
|
|
/>
|
|
<span>Custom memory</span>
|
|
</div>
|
|
<div className="text-foreground-light flex gap-2 items-center">
|
|
<CpuIcon
|
|
strokeWidth={1}
|
|
size={14}
|
|
className="text-foreground-lighter"
|
|
/>
|
|
<span>Custom CPU</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SupportLink>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</RadioGroupCard>
|
|
</div>
|
|
)}
|
|
/>
|
|
)
|
|
}
|