mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
fix(billing): keep pricing motion local and soften digit edges
This commit is contained in:
6 files changed
+117
-106
No files matched your search
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in new issue
Block a user