fix(billing): keep pricing motion local and soften digit edges

This commit is contained in:
yyh committed 2026-11-10 19:06:48 +08:00
1 parent e9e6f562b0
commit fb858ef971
6 files changed
+117 -106

No files matched your search

+1 -3
View File
@@ -6,9 +6,7 @@ domain value type through its public API.
## Primitive choice
- [`RadioGroup`] selects one persistent field value from a visible set of options. Every `Radio` or
`RadioItem` belongs to a group; do not render a standalone radio. To animate a custom selected surface, place
`RadioGroupIndicator` first inside a positioned, isolated group and give its items relative
positioning. Keyboard focus and reduced-motion preferences select the new surface immediately.
`RadioItem` belongs to a group; do not render a standalone radio.
- `SegmentedControl` selects one mode, filter, or view. It follows radio-group semantics: an active
item cannot be toggled off, `Tab` enters on the selected item, and arrow keys move and select.
The primitive owns the track, focus ring and selection motion. Preserve the existing density,
@@ -89,8 +89,6 @@ function RadioSkeleton({ className, ...props }: RadioSkeletonProps) {
)
}
export { RadioGroupIndicator } from './indicator'
export { Radio, RadioControl, RadioGroup, RadioItem, RadioSkeleton }
export type {
@@ -1,93 +0,0 @@
'use client'
import * as React from 'react'
export function RadioGroupIndicator() {
const ref = React.useRef<HTMLSpanElement>(null)
React.useLayoutEffect(() => {
const thumb = ref.current!
const group = thumb.parentElement!
const getSelected = () => group.querySelector<HTMLElement>('[role="radio"][data-checked]')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
let selected = getSelected()
let slide: Animation | undefined
let hideFill: Animation | undefined
function stop() {
if (slide) {
slide.onfinish = null
slide.cancel()
}
hideFill?.cancel()
slide = undefined
thumb.hidden = true
}
function update() {
const next = getSelected()
if (next === selected) return
const from = (slide ? thumb : selected)?.getBoundingClientRect()
selected = next
stop()
if (!next || !from?.width || reducedMotion.matches || next.matches(':focus-visible')) return
const to = next.getBoundingClientRect()
if (!to.width || !to.height) return
// Paint the item's actual surface during the slide. Its layout and focus ring stay in place.
const surface = getComputedStyle(next)
Object.assign(thumb.style, {
width: surface.width,
height: surface.height,
backgroundColor: surface.backgroundColor,
border: surface.border,
borderRadius: surface.borderRadius,
boxShadow: surface.getPropertyValue('--tw-shadow'),
})
thumb.hidden = false
const origin = thumb.getBoundingClientRect()
const scaleX = origin.width / Number.parseFloat(surface.width)
const scaleY = origin.height / Number.parseFloat(surface.height)
const transform = (rect: DOMRect) =>
`translate(${(rect.x - origin.x) / scaleX}px, ${(rect.y - origin.y) / scaleY}px) scale(${rect.width / origin.width}, ${rect.height / origin.height})`
const timing: KeyframeAnimationOptions = {
duration: 180,
easing: 'cubic-bezier(0.2, 0, 0, 1)',
fill: 'both',
}
const clear = {
backgroundColor: 'transparent',
borderColor: 'transparent',
'--tw-shadow': '0 0 #0000',
}
hideFill = next.animate([clear, clear], timing)
slide = thumb.animate([{ transform: transform(from) }, { transform: transform(to) }], timing)
slide.onfinish = stop
}
const observer = new MutationObserver(update)
observer.observe(group, {
attributes: true,
attributeFilter: ['data-checked'],
childList: true,
subtree: true,
})
reducedMotion.addEventListener('change', stop)
return () => {
observer.disconnect()
reducedMotion.removeEventListener('change', stop)
stop()
}
}, [])
return (
<span
ref={ref}
hidden
aria-hidden="true"
className="pointer-events-none absolute inset-s-0 top-0 origin-top-left"
/>
)
}
@@ -1,12 +1,11 @@
'use client'
import type * as React from 'react'
import { Radio as BaseRadio } from '@base-ui/react/radio'
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'
import * as React from 'react'
import { cn } from '../cn'
import { focusRingClassName } from '../internal/focus'
import { resolveClassName } from '../internal/resolve-class-name'
import { RadioGroupIndicator } from '../radio-group/indicator'
type SegmentedControlSelectionProps<Value> =
| {
@@ -24,6 +23,96 @@ type SegmentedControlProps<Value = string> = Omit<
> &
SegmentedControlSelectionProps<Value>
function SelectionThumb() {
const ref = React.useRef<HTMLSpanElement>(null)
React.useLayoutEffect(() => {
const thumb = ref.current!
const group = thumb.parentElement!
const getSelected = () => group.querySelector<HTMLElement>('[role="radio"][data-checked]')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
let selected = getSelected()
let slide: Animation | undefined
let hideFill: Animation | undefined
function stop() {
if (slide) {
slide.onfinish = null
slide.cancel()
}
hideFill?.cancel()
slide = undefined
thumb.hidden = true
}
function update() {
const next = getSelected()
if (next === selected) return
const from = (slide ? thumb : selected)?.getBoundingClientRect()
selected = next
stop()
if (!next || !from?.width || reducedMotion.matches || next.matches(':focus-visible')) return
const to = next.getBoundingClientRect()
if (!to.width || !to.height) return
// Paint the item's actual surface during the slide. Its layout and focus ring stay in place.
const surface = getComputedStyle(next)
Object.assign(thumb.style, {
width: surface.width,
height: surface.height,
backgroundColor: surface.backgroundColor,
border: surface.border,
borderRadius: surface.borderRadius,
boxShadow: surface.getPropertyValue('--tw-shadow'),
})
thumb.hidden = false
const origin = thumb.getBoundingClientRect()
const scaleX = origin.width / Number.parseFloat(surface.width)
const scaleY = origin.height / Number.parseFloat(surface.height)
const transform = (rect: DOMRect) =>
`translate(${(rect.x - origin.x) / scaleX}px, ${(rect.y - origin.y) / scaleY}px) scale(${rect.width / origin.width}, ${rect.height / origin.height})`
const timing: KeyframeAnimationOptions = {
duration: 180,
easing: 'cubic-bezier(0.2, 0, 0, 1)',
fill: 'both',
}
const clear = {
backgroundColor: 'transparent',
borderColor: 'transparent',
'--tw-shadow': '0 0 #0000',
}
hideFill = next.animate([clear, clear], timing)
slide = thumb.animate([{ transform: transform(from) }, { transform: transform(to) }], timing)
slide.onfinish = stop
}
const observer = new MutationObserver(update)
observer.observe(group, {
attributes: true,
attributeFilter: ['data-checked'],
childList: true,
subtree: true,
})
reducedMotion.addEventListener('change', stop)
return () => {
observer.disconnect()
reducedMotion.removeEventListener('change', stop)
stop()
}
}, [])
return (
<span
ref={ref}
hidden
aria-hidden="true"
className="pointer-events-none absolute inset-s-0 top-0 origin-top-left"
/>
)
}
function SegmentedControl<Value = string>({
children,
className,
@@ -39,7 +128,7 @@ function SegmentedControl<Value = string>({
}
{...props}
>
<RadioGroupIndicator />
<SelectionThumb />
{children}
</BaseRadioGroup>
)
+16 -3
View File
@@ -1,8 +1,9 @@
import type { GetBillingSubscriptionData } from '@dify/contracts/api/console/billing/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { RadioGroup, RadioGroupIndicator, RadioItem } from '@langgenius/dify-ui/radio-group'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { motion, useReducedMotion } from 'motion/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
@@ -15,7 +16,7 @@ import styles from './pricing.module.css'
type BillingInterval = GetBillingSubscriptionData['query']['interval']
const intervalOptionClassName =
'relative flex min-w-0 flex-1 cursor-pointer flex-wrap items-center justify-center gap-1 rounded-full px-3 py-1 text-lg leading-6 font-medium text-(--pricing-subdued) focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-text-accent data-checked:bg-components-button-secondary-bg data-checked:text-(--pricing-ink) sm:min-w-[190px] sm:flex-none sm:px-6 sm:text-2xl sm:leading-[30px]'
'relative flex min-w-0 flex-1 cursor-pointer flex-wrap items-center justify-center gap-1 rounded-full px-3 py-1 text-lg leading-6 font-medium text-(--pricing-subdued) focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-text-accent data-checked:text-(--pricing-ink) sm:min-w-[190px] sm:flex-none sm:px-6 sm:text-2xl sm:leading-[30px]'
export function PricingContent() {
const { t } = useTranslation(['appApi', 'billing', 'common'])
@@ -49,6 +50,17 @@ export function PricingContent() {
: 'month'
const [selectedBillingInterval, setSelectedBillingInterval] = React.useState<BillingInterval>()
const billingInterval = selectedBillingInterval ?? defaultBillingInterval
const indicatorId = React.useId()
const reducedMotion = useReducedMotion()
const intervalIndicator = (
<motion.span
aria-hidden="true"
initial={false}
layoutId={reducedMotion ? undefined : indicatorId}
transition={{ duration: 0.18, ease: [0.2, 0, 0, 1] }}
className="pointer-events-none absolute inset-0 -z-1 rounded-full bg-components-button-secondary-bg motion-reduce:transform-none! motion-reduce:opacity-100!"
/>
)
const billing = currentCloudPlan
? {
currentPlan: currentCloudPlan,
@@ -69,11 +81,12 @@ export function PricingContent() {
onValueChange={setSelectedBillingInterval}
className="relative isolate mx-auto my-8 min-h-[49px] w-full max-w-full gap-0 rounded-full border border-(--pricing-switch-border) bg-(--pricing-switch) p-1 sm:w-fit"
>
<RadioGroupIndicator />
<RadioItem value="month" className={intervalOptionClassName}>
{billingInterval === 'month' && intervalIndicator}
{t(($) => $['pricing.monthly'], { ns: 'billing' })}
</RadioItem>
<RadioItem value="year" className={intervalOptionClassName}>
{billingInterval === 'year' && intervalIndicator}
{t(($) => $['pricing.annually'], { ns: 'billing' })}
<span className="font-[PricingInter,sans-serif] text-sm leading-[inherit] font-semibold text-text-accent sm:text-xl sm:leading-[inherit]">
{t(($) => $['pricing.savings'], { ns: 'billing', percent: 17 })}
@@ -1,3 +1,5 @@
import { cn } from '@langgenius/dify-ui/cn'
type RollingPriceProps = {
monthly: number
annual: number
@@ -23,10 +25,14 @@ export function RollingPrice({ monthly, annual, isYearly }: RollingPriceProps) {
return (
<span
key={place}
className={collapsed ? 'h-lh w-0 overflow-hidden' : 'h-lh w-[1ch] overflow-hidden'}
className={cn(
'flex h-lh justify-end overflow-hidden mask-[linear-gradient(to_bottom,transparent,black_16%,black_84%,transparent)]',
'motion-safe:transition-[width,opacity] motion-safe:duration-300 motion-safe:ease-out',
collapsed ? 'w-0 opacity-0' : 'w-[1ch] opacity-100',
)}
>
<span
className="block text-center whitespace-pre motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-[cubic-bezier(0.22,1,0.36,1)]"
className="block w-[1ch] shrink-0 text-center whitespace-pre motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(-${isYearly ? steps : 0}lh)` }}
>
{reel.join('\n')}