diff --git a/packages/raystack/components/accordion/accordion.module.css b/packages/raystack/components/accordion/accordion.module.css index 6c85bd0bd..a7e2ddf57 100644 --- a/packages/raystack/components/accordion/accordion.module.css +++ b/packages/raystack/components/accordion/accordion.module.css @@ -31,11 +31,19 @@ border-top: none; } -.accordion-trigger:hover, -.accordion-trigger:focus-visible { +.accordion-trigger:hover { background-color: var(--rs-color-background-base-primary-hover); } +.accordion-trigger:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: -2px; +} + +.accordion-trigger:active:not(:disabled) { + background-color: var(--rs-color-background-neutral-secondary); +} + .accordion-trigger:disabled { pointer-events: none; opacity: 0.5; @@ -80,10 +88,10 @@ @media (prefers-reduced-motion: no-preference) { .accordion-icon { - transition: transform 150ms ease-in-out; + transition: transform var(--rs-duration-fast) var(--rs-ease-in-out); } .accordion-content { - transition: height 150ms ease-out; + transition: height var(--rs-duration-fast) var(--rs-ease-out); } } diff --git a/packages/raystack/components/amount/amount.module.css b/packages/raystack/components/amount/amount.module.css new file mode 100644 index 000000000..ec87c783d --- /dev/null +++ b/packages/raystack/components/amount/amount.module.css @@ -0,0 +1,3 @@ +.amount { + font-variant-numeric: tabular-nums; +} diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 21099431f..c30f1e2ac 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -1,4 +1,6 @@ +import { cx } from 'class-variance-authority'; import { type ComponentProps } from 'react'; +import styles from './amount.module.css'; export interface AmountProps extends ComponentProps<'span'> { /** @@ -167,6 +169,7 @@ export const Amount = ({ groupDigits = true, valueInMinorUnits = true, hideCurrency = false, + className, ...props }: AmountProps) => { try { @@ -265,10 +268,18 @@ export const Amount = ({ finalBaseValue ); - return {formattedValue}; + return ( + + {formattedValue} + + ); } catch (error) { console.error('Error formatting amount:', error); - return {String(value)}; + return ( + + {String(value)} + + ); } }; diff --git a/packages/raystack/components/announcement-bar/__tests__/announcement-bar.test.tsx b/packages/raystack/components/announcement-bar/__tests__/announcement-bar.test.tsx index a9c3f60aa..cee95600c 100644 --- a/packages/raystack/components/announcement-bar/__tests__/announcement-bar.test.tsx +++ b/packages/raystack/components/announcement-bar/__tests__/announcement-bar.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { AnnouncementBar } from '../announcement-bar'; import styles from '../announcement-bar.module.css'; @@ -119,4 +119,65 @@ describe('AnnouncementBar', () => { expect(actionBtn).toHaveClass(styles['action-btn']); }); }); + + describe('Dismissible', () => { + it('does not render a dismiss button by default', () => { + render(); + expect( + screen.queryByRole('button', { name: 'Dismiss announcement' }) + ).not.toBeInTheDocument(); + }); + + it('renders a dismiss button when dismissible', () => { + render(); + expect( + screen.getByRole('button', { name: 'Dismiss announcement' }) + ).toBeInTheDocument(); + }); + + it('calls onDismiss after the exit animation when controlled', () => { + vi.useFakeTimers(); + try { + const onDismiss = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss announcement' }) + ); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(screen.getByText('Test')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('removes itself after the exit animation when uncontrolled', () => { + vi.useFakeTimers(); + try { + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss announcement' }) + ); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect( + screen.queryByText('Uncontrolled dismiss') + ).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/packages/raystack/components/announcement-bar/announcement-bar.module.css b/packages/raystack/components/announcement-bar/announcement-bar.module.css index f0a249bf9..4c112bb73 100644 --- a/packages/raystack/components/announcement-bar/announcement-bar.module.css +++ b/packages/raystack/components/announcement-bar/announcement-bar.module.css @@ -2,6 +2,7 @@ width: 100%; min-height: var(--rs-space-10); padding: var(--rs-space-3) var(--rs-space-7); + position: relative; /* anchors .dismiss-btn */ } .announcement-bar-gradient { @@ -25,7 +26,9 @@ .announcement-bar-normal > .icon, .announcement-bar-error > .icon, .announcement-bar-normal > .action-btn, -.announcement-bar-error > .action-btn { +.announcement-bar-error > .action-btn, +.announcement-bar-normal > .dismiss-btn, +.announcement-bar-error > .dismiss-btn { color: var(--rs-color-foreground-base-emphasis); } @@ -50,4 +53,63 @@ padding: 0; font: inherit; color: inherit; + border-radius: var(--rs-radius-1); + transition: var(--rs-transition-interactive); +} + +.action-btn:hover { + opacity: 0.8; +} + +.action-btn:active { + opacity: 0.64; +} + +.action-btn:focus-visible { + outline: 1px solid currentColor; + outline-offset: 2px; +} + +.dismiss-btn { + position: absolute; + right: var(--rs-space-3); + top: 50%; + translate: 0 -50%; + color: inherit; +} + +.dismiss-btn:hover:not(:disabled), +.dismiss-btn:active:not(:disabled) { + background-color: transparent; + color: inherit; + opacity: 0.8; +} + +.dismiss-btn:focus-visible { + outline: 1px solid currentColor; + outline-offset: 2px; +} + +/* Grid-row collapse: fade + height-to-zero without measuring. */ +.collapse { + display: grid; + grid-template-rows: 1fr; +} + +.collapse-inner { + min-height: 0; + overflow: hidden; +} + +.collapse-closing { + grid-template-rows: 0fr; + opacity: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .collapse { + transition: + grid-template-rows var(--rs-duration-normal) var(--rs-ease-out), + opacity var(--rs-duration-normal) var(--rs-ease-out); + } } diff --git a/packages/raystack/components/announcement-bar/announcement-bar.tsx b/packages/raystack/components/announcement-bar/announcement-bar.tsx index 015cc0d31..1cb0d22c7 100644 --- a/packages/raystack/components/announcement-bar/announcement-bar.tsx +++ b/packages/raystack/components/announcement-bar/announcement-bar.tsx @@ -1,12 +1,17 @@ 'use client'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { ReactNode } from 'react'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { ReactNode, useEffect, useState } from 'react'; import { Flex } from '../flex'; +import { IconButton } from '../icon-button'; import { Text } from '../text'; import styles from './announcement-bar.module.css'; +// Must match --rs-duration-normal used by .collapse in announcement-bar.module.css. +const DISMISS_EXIT_MS = 200; + const announcementBar = cva(styles['announcement-bar'], { variants: { variant: { @@ -27,6 +32,14 @@ type AnnouncementBarProps = VariantProps & { actionLabel?: string; actionIcon?: ReactNode; onActionClick?: () => void; + /** Show a dismiss (close) button. */ + dismissible?: boolean; + /** + * Called after the dismiss animation finishes. When provided, the consumer + * owns removal (the bar stays mounted, collapsed). When omitted, the bar + * removes itself. + */ + onDismiss?: () => void; }; export const AnnouncementBar = ({ @@ -37,9 +50,26 @@ export const AnnouncementBar = ({ actionLabel, actionIcon, onActionClick = () => {}, + dismissible, + onDismiss, ...props }: AnnouncementBarProps) => { - return ( + const [dismissState, setDismissState] = useState< + 'open' | 'closing' | 'closed' + >('open'); + + useEffect(() => { + if (dismissState !== 'closing') return; + const timer = setTimeout(() => { + setDismissState('closed'); + onDismiss?.(); + }, DISMISS_EXIT_MS); + return () => clearTimeout(timer); + }, [dismissState, onDismiss]); + + if (dismissState === 'closed' && !onDismiss) return null; + + const bar = ( ); + + if (!dismissible) return bar; + + return ( +
+
{bar}
+
+ ); }; AnnouncementBar.displayName = 'AnnouncementBar'; diff --git a/packages/raystack/components/avatar/avatar.module.css b/packages/raystack/components/avatar/avatar.module.css index d435c0351..3c08594f0 100644 --- a/packages/raystack/components/avatar/avatar.module.css +++ b/packages/raystack/components/avatar/avatar.module.css @@ -11,10 +11,11 @@ width: var(--rs-space-9, 32px); height: var(--rs-space-9, 32px); --fallback-font-size: calc(var(--rs-space-9, 32px) * 0.4); + --fallback-letter-spacing: 0.03em; } -.avatar-full { - border-radius: var(--rs-radius-full) !important; +.avatar.avatar-full { + border-radius: var(--rs-radius-full); } .avatar-disabled { @@ -228,6 +229,18 @@ vertical-align: middle; } +@keyframes avatar-image-in { + from { + opacity: 0; + } +} + +@media (prefers-reduced-motion: no-preference) { + .image-fade-in { + animation: avatar-image-in var(--rs-duration-fast) var(--rs-ease-out); + } +} + .fallback { width: 100%; height: 100%; @@ -236,14 +249,15 @@ justify-content: center; font-size: var(--fallback-font-size); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-large); - letter-spacing: var(--rs-letter-spacing-large); + line-height: 1; + letter-spacing: var(--fallback-letter-spacing, 0.03em); } .avatar-size-1 { width: var(--rs-space-5, 16px); height: var(--rs-space-5, 16px); --fallback-font-size: calc(var(--rs-space-5, 16px) * 0.4); + --fallback-letter-spacing: 0.05em; border-radius: var(--rs-radius-2); } @@ -251,6 +265,7 @@ width: var(--rs-space-6, 20px); height: var(--rs-space-6, 20px); --fallback-font-size: calc(var(--rs-space-6, 20px) * 0.4); + --fallback-letter-spacing: 0.05em; border-radius: var(--rs-radius-2); } @@ -258,6 +273,7 @@ width: var(--rs-space-7, 24px); height: var(--rs-space-7, 24px); --fallback-font-size: calc(var(--rs-space-7, 24px) * 0.4); + --fallback-letter-spacing: 0.04em; border-radius: var(--rs-radius-2); } @@ -265,6 +281,7 @@ width: var(--rs-space-8, 28px); height: var(--rs-space-8, 28px); --fallback-font-size: calc(var(--rs-space-8, 28px) * 0.35); + --fallback-letter-spacing: 0.04em; border-radius: var(--rs-radius-2); } @@ -272,6 +289,7 @@ width: var(--rs-space-9, 32px); height: var(--rs-space-9, 32px); --fallback-font-size: calc(var(--rs-space-9, 32px) * 0.4); + --fallback-letter-spacing: 0.03em; border-radius: var(--rs-radius-2); } @@ -279,6 +297,7 @@ width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); --fallback-font-size: calc(var(--rs-space-10, 40px) * 0.35); + --fallback-letter-spacing: 0.02em; border-radius: var(--rs-radius-4); } @@ -286,6 +305,7 @@ width: var(--rs-space-11, 48px); height: var(--rs-space-11, 48px); --fallback-font-size: calc(var(--rs-space-11, 48px) * 0.35); + --fallback-letter-spacing: 0.01em; border-radius: var(--rs-radius-4); } @@ -293,6 +313,7 @@ width: var(--rs-space-12, 56px); height: var(--rs-space-12, 56px); --fallback-font-size: calc(var(--rs-space-12, 56px) * 0.3); + --fallback-letter-spacing: 0.01em; border-radius: var(--rs-radius-4); } @@ -300,6 +321,7 @@ width: var(--rs-space-13, 64px); height: var(--rs-space-13, 64px); --fallback-font-size: calc(var(--rs-space-13, 64px) * 0.3); + --fallback-letter-spacing: 0em; border-radius: var(--rs-radius-4); } @@ -307,6 +329,7 @@ width: var(--rs-space-14, 72px); height: var(--rs-space-14, 72px); --fallback-font-size: calc(var(--rs-space-14, 72px) * 0.3); + --fallback-letter-spacing: 0em; border-radius: var(--rs-radius-5); } @@ -314,6 +337,7 @@ width: var(--rs-space-15, 80px); height: var(--rs-space-15, 80px); --fallback-font-size: calc(var(--rs-space-15, 80px) * 0.3); + --fallback-letter-spacing: 0em; border-radius: var(--rs-radius-5); } @@ -321,6 +345,7 @@ width: var(--rs-space-16, 96px); height: var(--rs-space-16, 96px); --fallback-font-size: calc(var(--rs-space-16, 96px) * 0.3); + --fallback-letter-spacing: -0.005em; border-radius: var(--rs-radius-5); } @@ -328,6 +353,7 @@ width: var(--rs-space-17, 120px); height: var(--rs-space-17, 120px); --fallback-font-size: calc(var(--rs-space-17, 120px) * 0.3); + --fallback-letter-spacing: -0.01em; border-radius: var(--rs-radius-5); } diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx index 48dde77eb..826a59be2 100644 --- a/packages/raystack/components/avatar/avatar.tsx +++ b/packages/raystack/components/avatar/avatar.tsx @@ -1,9 +1,21 @@ +'use client'; + import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'; import { cva, cx, VariantProps } from 'class-variance-authority'; -import { ComponentProps, isValidElement, ReactElement, ReactNode } from 'react'; +import { + ComponentProps, + isValidElement, + ReactElement, + ReactNode, + useRef, + useState +} from 'react'; import styles from './avatar.module.css'; import { AVATAR_COLORS } from './utils'; +// Matches Base UI's AvatarRoot ImageLoadingStatus union. +type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; + const avatar = cva(styles.avatar, { variants: { radius: { @@ -164,21 +176,40 @@ const AvatarRoot = ({ variant, color, ...props -}: AvatarProps) => ( - - - - {fallback} - - -); +}: AvatarProps) => { + const loadingStartRef = useRef(0); + const [fadeIn, setFadeIn] = useState(false); + const handleLoadingStatusChange = (status: ImageLoadingStatus) => { + if (status === 'loading') { + loadingStartRef.current = performance.now(); + setFadeIn(false); + } else if (status === 'loaded') { + // Cache-fast loads (<100ms) mount without the fade. + setFadeIn(performance.now() - loadingStartRef.current > 100); + } + }; + + return ( + + + + {fallback} + + + ); +}; AvatarRoot.displayName = 'Avatar'; diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index e921ca908..01acacdf6 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -1,5 +1,5 @@ .breadcrumb { - font-weight: var(--rs-font-weight-medium); + font-weight: var(--rs-font-weight-regular); } .breadcrumb-small { @@ -37,12 +37,20 @@ text-wrap: nowrap; gap: var(--rs-space-2); cursor: pointer; + transition: color var(--rs-duration-fast) var(--rs-ease-out); } .breadcrumb-link:hover { color: var(--rs-color-foreground-base-secondary); } +.breadcrumb-link:focus-visible { + outline: none; + background-color: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-secondary); + border-radius: var(--rs-radius-1); +} + .breadcrumb-link-active { color: var(--rs-color-foreground-base-primary); font-weight: var(--rs-font-weight-medium); @@ -81,6 +89,12 @@ padding: 0; } +.breadcrumb-dropdown-trigger:focus-visible { + background-color: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-primary); + border-radius: var(--rs-radius-1); +} + .breadcrumb-dropdown-icon { height: var(--rs-space-3); } diff --git a/packages/raystack/components/button/button.module.css b/packages/raystack/components/button/button.module.css index 65ff5385e..73ce263be 100644 --- a/packages/raystack/components/button/button.module.css +++ b/packages/raystack/components/button/button.module.css @@ -16,7 +16,8 @@ } .button:focus-visible { - outline: 1px solid var(--rs-color-border-accent-emphasis); + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; } .button:focus { @@ -208,10 +209,18 @@ background-color: inherit; } +.button:active:not(:disabled):not(.button-disabled):not(.button-loading) { + transform: scale(0.97); +} + @media (prefers-reduced-motion: no-preference) { .button { transition: var(--rs-transition-interactive); } + + .button:active { + transition: var(--rs-transition-pressed); + } } .loader-text { diff --git a/packages/raystack/components/calendar/calendar.module.css b/packages/raystack/components/calendar/calendar.module.css index 6fa3fc589..e2f5bb475 100644 --- a/packages/raystack/components/calendar/calendar.module.css +++ b/packages/raystack/components/calendar/calendar.module.css @@ -73,7 +73,7 @@ width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); background-color: var(--rs-color-background-base-primary); - border-radius: var(--rs-radius-2); + border-radius: var(--rs-radius-5); color: var(--rs-color-foreground-base-primary); font-style: normal; text-align: center; @@ -92,11 +92,22 @@ width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); flex-shrink: 0; - border-radius: var(--rs-radius-5); border: 1px solid var(--rs-color-border-accent-emphasis-hover); background-color: transparent; } +.day:not(.disabled):not(.outside):not(.selected) .dayButton:active { + background-color: var(--rs-color-background-base-primary-hover); +} + +.selected:not(.rangeMiddle) .dayButton:active { + background-color: var(--rs-color-background-accent-emphasis-hover); +} + +.rangeMiddle .dayButton:active { + background-color: var(--rs-color-background-neutral-secondary); +} + .selected:not(.rangeMiddle) { width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); @@ -287,6 +298,65 @@ margin-top: var(--rs-space-2); } +.monthGridWrap { + position: relative; +} + +.monthGridSkeleton { + position: absolute; + inset: 0; + /* Solid backing so the grid underneath doesn't ghost through mid-fade */ + background: var(--rs-color-background-base-primary); + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.monthGridSkeleton[data-visible] { + opacity: 1; + visibility: visible; + /* Block clicks on the day grid underneath while loading */ + pointer-events: auto; +} + +@media (prefers-reduced-motion: no-preference) { + .monthGridSkeleton { + /* Exiting: fade opacity, then flip visibility after the fade */ + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + visibility 0s linear var(--rs-duration-fast); + } + + .monthGridSkeleton[data-visible] { + /* Entering: visibility flips immediately, opacity fades in */ + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} + .datePickerInput { text-align: left; } + +.datePickerError { + display: block; + /* Reserved line: the space exists whether or not an error is showing, + so appearing/disappearing errors never shift the layout below. */ + min-height: var(--rs-line-height-mini); + margin-top: var(--rs-space-1); + color: var(--rs-color-foreground-danger-primary); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + opacity: 0; +} + +.datePickerError[data-visible] { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .datePickerError { + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} diff --git a/packages/raystack/components/calendar/calendar.tsx b/packages/raystack/components/calendar/calendar.tsx index 2cc0c8bc8..ebc758c8e 100644 --- a/packages/raystack/components/calendar/calendar.tsx +++ b/packages/raystack/components/calendar/calendar.tsx @@ -194,17 +194,23 @@ export const Calendar = function ({ ); }, - MonthGrid: props => - loadingData ? ( - - ) : ( + MonthGrid: props => ( +
- ) + + + ) }} classNames={{ caption_label: styles.captionLabel, diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx index b50771627..ea07e1f4b 100644 --- a/packages/raystack/components/calendar/date-picker.tsx +++ b/packages/raystack/components/calendar/date-picker.tsx @@ -208,20 +208,29 @@ export function DatePicker({ } const defaultTrigger = ( - : undefined} - {...inputProps} - ref={popover.inputRef} - value={inputValue} - onChange={handleInputChange} - onFocus={popover.handleInputFocus} - onBlur={popover.handleInputBlur} - onKeyUp={handleKeyUp} - /> + <> + : undefined} + {...inputProps} + ref={popover.inputRef} + value={inputValue} + onChange={handleInputChange} + onFocus={popover.handleInputFocus} + onBlur={popover.handleInputBlur} + onKeyUp={handleKeyUp} + /> + + {error} + + ); /* diff --git a/packages/raystack/components/callout/__tests__/callout.test.tsx b/packages/raystack/components/callout/__tests__/callout.test.tsx index f93e860eb..6d872bd06 100644 --- a/packages/raystack/components/callout/__tests__/callout.test.tsx +++ b/packages/raystack/components/callout/__tests__/callout.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { Callout } from '../callout'; import styles from '../callout.module.css'; @@ -141,14 +141,26 @@ describe('Callout', () => { }); it('hides itself after dismiss when onDismiss is omitted (uncontrolled)', () => { - render(Uncontrolled message); - - fireEvent.click(screen.getByRole('button', { name: 'Dismiss message' })); - - expect( - screen.queryByText('Uncontrolled message') - ).not.toBeInTheDocument(); - expect(screen.queryByRole('status')).not.toBeInTheDocument(); + vi.useFakeTimers(); + try { + render(Uncontrolled message); + + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss message' }) + ); + + // Exit transition plays first; the callout unmounts after EXIT_MS. + act(() => { + vi.advanceTimersByTime(200); + }); + + expect( + screen.queryByText('Uncontrolled message') + ).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } }); it('renders both action and dismissible button', () => { diff --git a/packages/raystack/components/callout/callout.module.css b/packages/raystack/components/callout/callout.module.css index 3c73da0e6..330d14601 100644 --- a/packages/raystack/components/callout/callout.module.css +++ b/packages/raystack/components/callout/callout.module.css @@ -60,15 +60,20 @@ align-items: center; } -/* Dismiss button stays flat: keep the transparent background and base colour on - hover/active (overrides IconButton's hover feedback). The descendant selector +/* Dismiss button stays flat: keep the transparent background and base colour + on hover (overrides IconButton's hover feedback). The descendant selector out-specifies `.iconButton:hover` regardless of stylesheet order. */ -.callout .dismiss:hover:not(:disabled), -.callout .dismiss:active:not(:disabled) { +.callout .dismiss:hover:not(:disabled) { background-color: transparent; color: var(--rs-color-foreground-base-secondary); } +/* Pressed: background stays flat by design; the glyph darkens one step. */ +.callout .dismiss:active:not(:disabled) { + background-color: transparent; + color: var(--rs-color-foreground-base-primary); +} + /* Type Variants */ .callout-grey { background: var(--rs-color-background-neutral-primary); @@ -248,3 +253,36 @@ border: 0.5px solid var(--rs-color-border-base-tertiary); color: var(--rs-color-foreground-base-primary); } + +/* Enter/exit shell. Grid rows animate 1fr -> 0fr so the dismiss collapses the + callout's height without JS measurement; the inner wrapper clips the + shrinking row. */ +.transitionShell { + display: grid; + grid-template-rows: 1fr; +} + +.transitionInner { + min-height: 0; + overflow: hidden; +} + +.transitionShell[data-state="closing"] { + grid-template-rows: 0fr; + opacity: 0; +} + +@keyframes callout-enter { + from { + opacity: 0; + } +} + +@media (prefers-reduced-motion: no-preference) { + .transitionShell { + animation: callout-enter var(--rs-duration-normal) var(--rs-ease-out); + transition: + grid-template-rows var(--rs-duration-normal) var(--rs-ease-out), + opacity var(--rs-duration-normal) var(--rs-ease-out); + } +} diff --git a/packages/raystack/components/callout/callout.tsx b/packages/raystack/components/callout/callout.tsx index f5f587e47..9e6d4c422 100644 --- a/packages/raystack/components/callout/callout.tsx +++ b/packages/raystack/components/callout/callout.tsx @@ -6,12 +6,16 @@ import { type ComponentProps, type CSSProperties, type ReactNode, + useEffect, useState } from 'react'; import { IconButton } from '../icon-button'; import styles from './callout.module.css'; +/** Exit duration. Keep in sync with --rs-duration-normal (styles/effects.css:38). */ +const EXIT_MS = 200; + const callout = cva(styles.callout, { variants: { type: { @@ -65,45 +69,56 @@ export function Callout({ ...props }: CalloutProps) { // Dismissal is controlled when `onDismiss` is given; otherwise fall back to - // uncontrolled and hide the callout internally. - const [dismissed, setDismissed] = useState(false); + // uncontrolled: play the exit transition, then unmount. + const [state, setState] = useState<'open' | 'closing' | 'closed'>('open'); + + useEffect(() => { + if (state !== 'closing') return; + const timer = setTimeout(() => setState('closed'), EXIT_MS); + return () => clearTimeout(timer); + }, [state]); + const handleDismiss = () => { onDismiss?.(); - if (!onDismiss) setDismissed(true); + if (!onDismiss) setState('closing'); }; - if (dismissed) return null; + if (state === 'closed') return null; const role = type === 'alert' ? 'alert' : 'status'; return ( -
-
-
- {icon && ( -
; +const row = cva(styles['row'], { + variants: { + interactive: { + true: styles['row-interactive'] + } + } +}); +function TableRow({ + className, + interactive, + ...props +}: ComponentProps<'tr'> & VariantProps) { + return ; } TableRow.displayName = 'Table.Row'; diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index ec0b76d9a..9283e8885 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -50,7 +50,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; border-radius: var(--rs-radius-2); - transition: color 0.2s ease; + transition: color var(--rs-duration-normal) var(--rs-ease-out); flex: 1; text-align: center; text-overflow: ellipsis; @@ -79,6 +79,17 @@ pointer-events: none; } +.trigger:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + +/* Pressed: one step darker than the list surface. Applies instantly + (no background transition on the trigger) — correct for a press. */ +.trigger:active:not([data-disabled]):not([data-active]) { + background-color: var(--rs-color-background-neutral-secondary-hover); +} + .trigger-icon { display: inline-flex; width: var(--rs-space-5); @@ -127,9 +138,22 @@ box-shadow: none; } -.variant-standalone .indicator, +/* Standalone: the indicator is the active chip fill, gliding between tabs. */ +.variant-standalone .indicator { + background-color: var(--rs-color-background-neutral-primary); + border: 0.5px solid var(--rs-color-border-base-secondary); + box-sizing: border-box; + box-shadow: none; +} + +/* Plain: the indicator is a 1px underline sliding along the bottom edge. */ .variant-plain .indicator { - display: none; + height: 1px; + translate: var(--active-tab-left, 0px) + calc(var(--active-tab-top, 0px) + var(--active-tab-height, 0px) - 1px); + background-color: var(--rs-color-border-base-emphasis); + border-radius: 0; + box-shadow: none; } /* Standalone variant: chip-like tabs with independent borders */ @@ -140,8 +164,8 @@ } .variant-standalone .trigger[data-active] { - background: var(--rs-color-background-neutral-primary); - border: 0.5px solid var(--rs-color-border-base-secondary); + background: transparent; + border-color: transparent; } /* Plain variant: text-like tabs with larger gaps */ @@ -161,6 +185,8 @@ border-bottom: 1px solid transparent; } -.variant-plain .trigger[data-active] { - border-bottom-color: var(--rs-color-border-base-emphasis); -} \ No newline at end of file +/* Plain variant is text-only: dim on press instead of painting a box. */ +.variant-plain .trigger:active:not([data-disabled]):not([data-active]) { + background-color: transparent; + opacity: 0.7; +} diff --git a/packages/raystack/components/text-area/text-area.module.css b/packages/raystack/components/text-area/text-area.module.css index f7bde8a0c..59b242b20 100644 --- a/packages/raystack/components/text-area/text-area.module.css +++ b/packages/raystack/components/text-area/text-area.module.css @@ -87,3 +87,7 @@ border-color: transparent; outline: none; } + +.variant-borderless:focus-visible:not(:disabled) { + box-shadow: 0 0 0 2px var(--rs-color-border-accent-primary); +} diff --git a/packages/raystack/components/text/__tests__/text.test.tsx b/packages/raystack/components/text/__tests__/text.test.tsx index 725e7bf5f..b02fcfc10 100644 --- a/packages/raystack/components/text/__tests__/text.test.tsx +++ b/packages/raystack/components/text/__tests__/text.test.tsx @@ -169,7 +169,7 @@ describe('Text', () => { expect(container.firstChild).toHaveClass(styles['text-strike-through']); expect(container.firstChild).toHaveClass(styles['text-underline']); expect(container.firstChild).toHaveClass( - styles['text-italic-strike-through'] + styles['text-underline-strike-through'] ); }); }); diff --git a/packages/raystack/components/text/text.module.css b/packages/raystack/components/text/text.module.css index eaa577886..755e1d77b 100644 --- a/packages/raystack/components/text/text.module.css +++ b/packages/raystack/components/text/text.module.css @@ -138,7 +138,7 @@ text-decoration-line: line-through; } -.text-italic-strike-through { +.text-underline-strike-through { text-decoration-line: underline line-through; } diff --git a/packages/raystack/components/text/text.tsx b/packages/raystack/components/text/text.tsx index 63470d466..fcd72a99f 100644 --- a/packages/raystack/components/text/text.tsx +++ b/packages/raystack/components/text/text.tsx @@ -62,7 +62,7 @@ export const textVariants = cva(styles.text, { { strikeThrough: true, underline: true, - className: styles['text-italic-strike-through'] + className: styles['text-underline-strike-through'] } ] }); diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index 7183796d7..3c531dbdc 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -167,6 +167,10 @@ transform: translateY(calc(-100% - var(--rs-space-7))); } +.root[data-starting-style] { + opacity: 0; +} + /* ===== Limited state ===== */ .root[data-limited] { opacity: 0; @@ -207,7 +211,7 @@ /* ===== Content (stacking visibility) ===== */ .content { - transition: opacity 250ms; + transition: opacity var(--rs-duration-moderate) var(--rs-ease-out); } .content[data-behind] { diff --git a/packages/raystack/components/toggle/toggle.module.css b/packages/raystack/components/toggle/toggle.module.css index 7cc8a4d12..db187515f 100644 --- a/packages/raystack/components/toggle/toggle.module.css +++ b/packages/raystack/components/toggle/toggle.module.css @@ -6,7 +6,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; padding: var(--rs-space-1); - transition: color 0.2s ease; + transition: color var(--rs-duration-normal) var(--rs-ease-out); background-color: var(--rs-color-background-base-secondary); border: 0.5px solid var(--rs-color-border-base-primary); border-radius: var(--rs-radius-1); @@ -24,21 +24,72 @@ height: 100%; } -.toggle:hover, +@media (prefers-reduced-motion: no-preference) { + .toggleContent { + transition: + background-color var(--rs-duration-fast) var(--rs-ease-out), + box-shadow var(--rs-duration-fast) var(--rs-ease-out); + } + + .group .toggle { + /* color entry matches .toggle's own transition (line 9) so both are + driven by the same token */ + transition: + color var(--rs-duration-normal) var(--rs-ease-out), + background-color var(--rs-duration-fast) var(--rs-ease-out), + box-shadow var(--rs-duration-fast) var(--rs-ease-out); + } +} + +.toggle:hover { + color: var(--rs-color-foreground-base-primary); +} +.toggle:hover .toggleContent { + background-color: var(--rs-color-background-base-primary-hover); +} + .toggle[data-pressed] { color: var(--rs-color-foreground-base-primary); } -.toggle:hover .toggleContent, .toggle[data-pressed] .toggleContent { - background-color: var(--rs-color-background-base-primary-hover); + background-color: var(--rs-color-background-neutral-secondary); + box-shadow: var(--rs-shadow-inset); +} +.toggle[data-pressed]:hover .toggleContent { + background-color: var(--rs-color-background-neutral-secondary-hover); } .toggle[data-disabled] { opacity: 0.5; - pointer-events: none; + pointer-events: initial; cursor: not-allowed; } +.toggle[data-disabled]:not([data-pressed]):hover { + color: var(--rs-color-foreground-base-secondary); +} + +.toggle[data-disabled]:not([data-pressed]):hover .toggleContent { + background-color: transparent; + box-shadow: none; +} + +.group .toggle[data-disabled]:not([data-pressed]):hover { + background-color: transparent; + box-shadow: none; +} + +.toggle:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + +/* Inside a group the container clips overflow and items sit 1px apart: + keep the ring fully inside the item. */ +.group .toggle:focus-visible { + outline-offset: -2px; +} + .size-1 { width: var(--rs-space-4); height: var(--rs-space-4); @@ -49,6 +100,27 @@ height: var(--rs-space-5); } +/* Expand sub-24px hit targets to 24px (WCAG 2.5.8); visible size unchanged. + size-1: (24 − 12) / 2 = 6px; size-2: (24 − 16) / 2 = 4px. + Inside .group (overflow: clip, 1px gaps) the expansion is clipped at the + group edge — that is a known limit; do not change the group's overflow. */ +.size-1, +.size-2 { + position: relative; +} + +.size-1::before { + content: ""; + position: absolute; + inset: -6px; +} + +.size-2::before { + content: ""; + position: absolute; + inset: -4px; +} + .size-3 { width: var(--rs-space-6); height: var(--rs-space-6); @@ -79,8 +151,7 @@ } .group .toggle:hover { - background-color: var(--rs-color-background-base-primary); - box-shadow: var(--rs-shadow-inset); + background-color: var(--rs-color-background-base-primary-hover); } .group .toggle[data-pressed] { @@ -95,6 +166,6 @@ .group[data-disabled] { opacity: 0.5; - pointer-events: none; + pointer-events: initial; cursor: not-allowed; } diff --git a/packages/raystack/components/toolbar/toolbar.module.css b/packages/raystack/components/toolbar/toolbar.module.css index e9b76ca5f..02b1b44af 100644 --- a/packages/raystack/components/toolbar/toolbar.module.css +++ b/packages/raystack/components/toolbar/toolbar.module.css @@ -37,5 +37,5 @@ } .group { padding: 0; - background-color: none; + background-color: transparent; } diff --git a/packages/raystack/components/tooltip/tooltip.module.css b/packages/raystack/components/tooltip/tooltip.module.css index d7cac3666..9639d967a 100644 --- a/packages/raystack/components/tooltip/tooltip.module.css +++ b/packages/raystack/components/tooltip/tooltip.module.css @@ -21,7 +21,7 @@ letter-spacing: var(--rs-letter-spacing-mini); width: fit-content; max-width: 400px; - transform-origin: var(--transform-origin); + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); } .arrow svg { @@ -55,107 +55,68 @@ transform: translateY(-50%) translateX(-100%) rotate(90deg); } -@keyframes fade { - from { - opacity: 0; - } - to { - opacity: 1; - } +.content[data-starting-style], +.content[data-ending-style] { + opacity: 0; } -@keyframes slideUpAndFade { - from { - opacity: 0; - transform: translateY(var(--rs-space-1)); - } + +/* Base UI sets data-instant (focus/click/dismiss and grouped-trigger moves): + skip the animation but keep the transition machinery. */ +.content[data-instant] { + transition-duration: 0ms; } -@keyframes slideRightAndFade { - from { - opacity: 0; - transform: translateX(calc(-1 * var(--rs-space-1))); + +@media (prefers-reduced-motion: no-preference) { + .content { + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + transform var(--rs-duration-fast) var(--rs-ease-out); } -} -@keyframes slideDownAndFade { - from { - opacity: 0; + /* Per-side directional offset — same vectors as the old keyframes */ + .content[data-side="top"][data-starting-style], + .content[data-side="top"][data-ending-style] { transform: translateY(calc(-1 * var(--rs-space-1))); } -} -@keyframes slideLeftAndFade { - from { - opacity: 0; + .content[data-side="right"][data-starting-style], + .content[data-side="right"][data-ending-style] { transform: translateX(var(--rs-space-1)); } -} -@keyframes slideDownRightAndFade { - from { - opacity: 0; + .content[data-side="bottom"][data-starting-style], + .content[data-side="bottom"][data-ending-style] { + transform: translateY(var(--rs-space-1)); + } + + .content[data-side="left"][data-starting-style], + .content[data-side="left"][data-ending-style] { + transform: translateX(calc(-1 * var(--rs-space-1))); + } + + /* Aligned variants get a diagonal offset — same vectors as before. + Higher specificity (0,4,0) than the side rules (0,3,0), so they win + when data-align is start/end. */ + .content[data-side="top"][data-align="start"][data-starting-style], + .content[data-side="top"][data-align="start"][data-ending-style] { transform: translate( calc(-1 * var(--rs-space-1)), calc(-1 * var(--rs-space-1)) ); } -} -@keyframes slideDownLeftAndFade { - from { - opacity: 0; + .content[data-side="top"][data-align="end"][data-starting-style], + .content[data-side="top"][data-align="end"][data-ending-style] { transform: translate(var(--rs-space-1), calc(-1 * var(--rs-space-1))); } -} -@keyframes slideUpRightAndFade { - from { - opacity: 0; + .content[data-side="bottom"][data-align="start"][data-starting-style], + .content[data-side="bottom"][data-align="start"][data-ending-style] { transform: translate(calc(-1 * var(--rs-space-1)), var(--rs-space-1)); } -} -@keyframes slideUpLeftAndFade { - from { - opacity: 0; + .content[data-side="bottom"][data-align="end"][data-starting-style], + .content[data-side="bottom"][data-align="end"][data-ending-style] { transform: translate(var(--rs-space-1), var(--rs-space-1)); } } - -@media (prefers-reduced-motion: no-preference) { - .content { - animation-duration: var(--rs-duration-fast); - animation-timing-function: var(--rs-ease-out); - } - - .content[data-open][data-side="top"]:not([data-instant]) { - animation-name: slideDownAndFade; - } - - .content[data-open][data-side="right"]:not([data-instant]) { - animation-name: slideLeftAndFade; - } - - .content[data-open][data-side="bottom"]:not([data-instant]) { - animation-name: slideUpAndFade; - } - - .content[data-open][data-side="left"]:not([data-instant]) { - animation-name: slideRightAndFade; - } - - .content[data-side="top"][data-align="start"]:not([data-instant]) { - animation-name: slideDownRightAndFade; - } - - .content[data-side="top"][data-align="end"]:not([data-instant]) { - animation-name: slideDownLeftAndFade; - } - - .content[data-side="bottom"][data-align="start"]:not([data-instant]) { - animation-name: slideUpRightAndFade; - } - - .content[data-side="bottom"][data-align="end"]:not([data-instant]) { - animation-name: slideUpLeftAndFade; - } -} diff --git a/packages/raystack/components/tour-beta/tour.module.css b/packages/raystack/components/tour-beta/tour.module.css index 7d16946b2..70caa62c8 100644 --- a/packages/raystack/components/tour-beta/tour.module.css +++ b/packages/raystack/components/tour-beta/tour.module.css @@ -52,7 +52,7 @@ otherwise the first open would animate in from the viewport origin. */ @media (prefers-reduced-motion: no-preference) { .positioner:has(.popup:not([data-starting-style])) { - transition: transform 350ms var(--rs-ease-out); + transition: transform var(--rs-duration-slow) var(--rs-ease-out); } } diff --git a/packages/raystack/components/tour/__tests__/tour.test.tsx b/packages/raystack/components/tour/__tests__/tour.test.tsx index 51b548a99..b1da384c8 100644 --- a/packages/raystack/components/tour/__tests__/tour.test.tsx +++ b/packages/raystack/components/tour/__tests__/tour.test.tsx @@ -324,6 +324,11 @@ describe('Tour', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument() ); + // The overlay itself holds for one exit fade after a real close — wait + // for it to fully unmount before the next assertions rely on its absence. + await waitFor(() => + expect(document.querySelector('[data-status]')).not.toBeInTheDocument() + ); fireEvent.click(screen.getByText('unmount')); // Resume onto the now-missing target. diff --git a/packages/raystack/components/tour/tour-overlay.tsx b/packages/raystack/components/tour/tour-overlay.tsx index a235d6cb0..c4be329ee 100644 --- a/packages/raystack/components/tour/tour-overlay.tsx +++ b/packages/raystack/components/tour/tour-overlay.tsx @@ -1,12 +1,17 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { type ComponentPropsWithoutRef, useEffect, useState } from 'react'; +import { + type ComponentPropsWithoutRef, + useEffect, + useRef, + useState +} from 'react'; import { createPortal } from 'react-dom'; import styles from './tour.module.css'; import { useTourContext } from './tour-context'; import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; -import { rectsEqual, type SpotlightRect } from './utils'; +import { FADE_OUT_MS, rectsEqual, type SpotlightRect } from './utils'; export interface TourOverlayProps extends ComponentPropsWithoutRef<'div'> { /** @@ -108,9 +113,74 @@ export function TourOverlay({ if (revealed) setEntered(true); }, [open, disabled, revealed]); - if (!open || !mounted || disabled || !step) return null; + const active = open && !disabled && step != null; // Requiring the live `spotlightElement` prevents an orphaned dim after a target unmounts. - if (targeted && (!spotlightElement || !displayed || !rect)) return null; + const orphaned = + active && targeted && (!spotlightElement || !displayed || !rect); + + // Frozen copy of the last-painted dim so it can fade out in place after close. + const exitFrameRef = useRef<{ + hole: { x: number; y: number; width: number; height: number } | null; + radius: number; + } | null>(null); + + const [exiting, setExiting] = useState(false); + // Tracks whether we last painted a real, live spotlight (not an orphaned + // gap) — only that state has a valid frame worth fading out from. + const wasShowingRef = useRef(false); + useEffect(() => { + if (active && !orphaned) { + wasShowingRef.current = true; + setExiting(false); + return; + } + if (orphaned) { + // Mid-tour orphan: hide instantly (no stale frame to fade from later). + wasShowingRef.current = false; + setExiting(false); + return; + } + if (!wasShowingRef.current) return; + wasShowingRef.current = false; + if (reducedMotion) return; // exits are instant under reduced motion + setExiting(true); + const timer = setTimeout(() => setExiting(false), FADE_OUT_MS); + return () => clearTimeout(timer); + }, [active, orphaned, reducedMotion]); + + if (!mounted) return null; + if (!active) { + // Closing: hold the dim for one fade, mirroring the entry. + if (!exiting || exitFrameRef.current == null) return null; + const frame = exitFrameRef.current; + return createPortal( +
+
+
, + document.body + ); + } + if (orphaned) return null; const padding = step.spotlightPadding ?? spotlightPadding; const radius = step.spotlightRadius ?? spotlightRadius; @@ -126,6 +196,8 @@ export function TourOverlay({ } : null; + exitFrameRef.current = { hole, radius }; + const holeOpen = fade ? shown : true; return createPortal( diff --git a/packages/raystack/components/tour/tour-root.tsx b/packages/raystack/components/tour/tour-root.tsx index 08d2a6c9b..313369e24 100644 --- a/packages/raystack/components/tour/tour-root.tsx +++ b/packages/raystack/components/tour/tour-root.tsx @@ -24,10 +24,7 @@ import type { } from './types'; import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; import { resolveTourTarget, useTourTarget } from './use-tour-target'; -import { rectsEqual } from './utils'; - -// Must match the overlay's opacity transition in `tour.module.css`. -const FADE_OUT_MS = 160; +import { FADE_OUT_MS, rectsEqual } from './utils'; const REVEAL_TIMEOUT_MS = 2000; diff --git a/packages/raystack/components/tour/tour.module.css b/packages/raystack/components/tour/tour.module.css index 6823972ff..3d7ab44a7 100644 --- a/packages/raystack/components/tour/tour.module.css +++ b/packages/raystack/components/tour/tour.module.css @@ -72,7 +72,7 @@ origin. `fade` mode repositions the card while it is hidden, so no glide. */ @media (prefers-reduced-motion: no-preference) { .positioner[data-transition="move"]:has(.popup:not([data-starting-style])) { - transition: transform 350ms var(--rs-ease-out); + transition: transform var(--rs-duration-slow) var(--rs-ease-out); } } diff --git a/packages/raystack/components/tour/utils.ts b/packages/raystack/components/tour/utils.ts index 6a6e42db7..bba343906 100644 --- a/packages/raystack/components/tour/utils.ts +++ b/packages/raystack/components/tour/utils.ts @@ -1,3 +1,6 @@ +// Must match --rs-duration-fast — the .spotlightCover fade in tour.module.css. +export const FADE_OUT_MS = 150; + export interface SpotlightRect { x: number; y: number; diff --git a/packages/raystack/styles/effects.css b/packages/raystack/styles/effects.css index 648f233dc..c668305bc 100644 --- a/packages/raystack/styles/effects.css +++ b/packages/raystack/styles/effects.css @@ -8,13 +8,28 @@ --rs-shadow-inset: 0px 1px 1px 0px oklch(0 0 0 / 0.04) inset; /* Transitions */ - --rs-transition-interactive: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + --rs-transition-interactive: + background-color var(--rs-duration-normal) var(--rs-ease-out), + border-color var(--rs-duration-normal) var(--rs-ease-out), + color var(--rs-duration-normal) var(--rs-ease-out), + box-shadow var(--rs-duration-normal) var(--rs-ease-out), + opacity var(--rs-duration-normal) var(--rs-ease-out); + /* Pressed states: acknowledge pointer-down near-instantly. Apply on + `:active`; releasing falls back to --rs-transition-interactive (200ms). */ + --rs-transition-pressed: + background-color var(--rs-duration-press) var(--rs-ease-out), + border-color var(--rs-duration-press) var(--rs-ease-out), + color var(--rs-duration-press) var(--rs-ease-out), + box-shadow var(--rs-duration-press) var(--rs-ease-out), + opacity var(--rs-duration-press) var(--rs-ease-out), + transform var(--rs-duration-press) var(--rs-ease-out); /* Motion */ /* Example usage: transition: opacity var(--rs-duration-normal) var(--rs-ease-out); */ --rs-ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, exits, value changes */ --rs-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* elements moving on screen */ --rs-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer slide */ + --rs-duration-press: 100ms; /* pressed/active feedback */ --rs-duration-fast: 150ms; /* tooltips, menus, small popups */ --rs-duration-normal: 200ms; /* popovers, dialogs, switches */ --rs-duration-moderate: 250ms; /* tabs indicator, preview card, tour steps */ diff --git a/packages/raystack/styles/typography.css b/packages/raystack/styles/typography.css index 3d9820895..eaee8a8cf 100644 --- a/packages/raystack/styles/typography.css +++ b/packages/raystack/styles/typography.css @@ -29,17 +29,17 @@ /* Body Line Heights */ --rs-line-height-micro: 12px; - --rs-line-height-mini: 16px; + --rs-line-height-mini: 14px; --rs-line-height-small: 16px; --rs-line-height-regular: 20px; --rs-line-height-large: 24px; /* Body Letter spacing */ --rs-letter-spacing-micro: 0.5px; - --rs-letter-spacing-mini: 0.5px; - --rs-letter-spacing-small: 0.4px; + --rs-letter-spacing-mini: 0.4px; + --rs-letter-spacing-small: 0.3px; --rs-letter-spacing-regular: 0.25px; - --rs-letter-spacing-large: 0.5px; + --rs-letter-spacing-large: 0px; /* Title Font Sizes */ --rs-font-size-t1: 20px; @@ -48,16 +48,16 @@ --rs-font-size-t4: 32px; /* Title Line Heights */ - --rs-line-height-t1: 24px; + --rs-line-height-t1: 28px; --rs-line-height-t2: 32px; --rs-line-height-t3: 36px; --rs-line-height-t4: 40px; /* Title Letter Spacing */ - --rs-letter-spacing-t1: 0; - --rs-letter-spacing-t2: 0; - --rs-letter-spacing-t3: 0; - --rs-letter-spacing-t4: 0; + --rs-letter-spacing-t1: -0.01em; + --rs-letter-spacing-t2: -0.013em; + --rs-letter-spacing-t3: -0.016em; + --rs-letter-spacing-t4: -0.02em; /* Mono Font Sizes */ --rs-font-size-mono-mini: 11px; @@ -103,19 +103,19 @@ h1 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t4); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t4); } h2 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t3); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t3); } h3 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t2); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t2); } h4 { font-family: var(--rs-font-title);
s. `.virtualRow .virtualCell` outspecifies `.cell`. */ +.virtualRow .virtualCell { + background: inherit; +} + .virtualSectionHeader { position: absolute; width: 100%; @@ -254,3 +286,24 @@ flex: 1; min-width: 0; } + +/* Scrolled-under edge for the virtualized sticky group anchor. The anchor is + permanently stuck and coincides with the first natural group header at + scroll 0, so "scrolled > 0" is exactly "content is underneath it". + (.stickySectionHeader is intentionally NOT gated: its many instances stick + at per-element offsets a scroll() timeline can't express — revisit with + @container scroll-state(stuck: top) when support matures.) */ +@supports (animation-timeline: scroll()) { + .stickyGroupAnchor { + box-shadow: 0 0.5px 0 0 transparent; + animation: anchor-edge linear both; + animation-timeline: scroll(nearest block); + animation-range: 0px 24px; + } +} + +@keyframes anchor-edge { + to { + box-shadow: 0 0.5px 0 0 var(--rs-color-border-base-primary); + } +} diff --git a/packages/raystack/components/data-view-beta/data-view.module.css b/packages/raystack/components/data-view-beta/data-view.module.css index 12078f2f7..b215dabf4 100644 --- a/packages/raystack/components/data-view-beta/data-view.module.css +++ b/packages/raystack/components/data-view-beta/data-view.module.css @@ -108,6 +108,16 @@ cursor: pointer; } +.clickable:active { + background: var(--rs-color-background-neutral-secondary); +} + +.row[data-state='selected'], +.row[data-state='selected']:hover, +.row[data-state='selected'].clickable:active { + background: var(--rs-color-background-accent-primary); +} + .head { position: sticky; top: 0; @@ -180,6 +190,13 @@ box-sizing: border-box; } +/* Let virtualized cells show the row's hover/selected/pressed color, like + the non-virtualized cells already do via inherited background. + `.virtualRow .virtualCell` outspecifies the base `.cell`/`.virtualCell` rule. */ +.virtualRow .virtualCell { + background: inherit; +} + .virtualSectionHeader { position: absolute; width: 100%; diff --git a/packages/raystack/components/data-view/components/ordering.tsx b/packages/raystack/components/data-view/components/ordering.tsx index 1fcaed09e..850954f13 100644 --- a/packages/raystack/components/data-view/components/ordering.tsx +++ b/packages/raystack/components/data-view/components/ordering.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TextAlignBottomIcon, TextAlignTopIcon } from '@radix-ui/react-icons'; +import { TextAlignBottomIcon } from '@radix-ui/react-icons'; import { Flex } from '../../flex'; import { IconButton } from '../../icon-button'; @@ -68,13 +68,10 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) { size={4} disabled={columnList.length === 0} > - {value.order === SortOrders?.ASC ? ( - - ) : ( - - )} + diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view/data-view.module.css index b61bbe31f..6fc804a1b 100644 --- a/packages/raystack/components/data-view/data-view.module.css +++ b/packages/raystack/components/data-view/data-view.module.css @@ -54,6 +54,16 @@ width: var(--rs-space-6); } +.display-popover-sort-icon[data-order="desc"] { + transform: rotate(180deg); +} + +@media (prefers-reduced-motion: no-preference) { + .display-popover-sort-icon { + transition: transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + .flex-1 { flex: 1; } @@ -205,6 +215,11 @@ background: var(--rs-color-background-base-primary-hover); } +.listRow.clickable:active, +.listRowVirtual.clickable:active { + background: var(--rs-color-background-neutral-secondary); +} + .listRowDivider { border-bottom: 0.5px solid var(--rs-color-border-base-primary); } @@ -326,3 +341,33 @@ width: 100%; box-sizing: border-box; } + +/* Scrolled-under edges: header hairline and group-anchor hairline appear only + once content scrolls beneath them. See table.module.css for rationale. */ +@supports (animation-timeline: scroll()) { + .listHeader { + border-bottom-color: transparent; + animation: list-header-edge linear both; + animation-timeline: scroll(nearest block); + animation-range: 0px 24px; + } + + .listGroupAnchor { + box-shadow: 0 0.5px 0 0 transparent; + animation: list-anchor-edge linear both; + animation-timeline: scroll(nearest block); + animation-range: 0px 24px; + } +} + +@keyframes list-header-edge { + to { + border-bottom-color: var(--rs-color-border-base-primary); + } +} + +@keyframes list-anchor-edge { + to { + box-shadow: 0 0.5px 0 0 var(--rs-color-border-base-primary); + } +} diff --git a/packages/raystack/components/dialog/dialog.module.css b/packages/raystack/components/dialog/dialog.module.css index 085c2eba8..1935b4212 100644 --- a/packages/raystack/components/dialog/dialog.module.css +++ b/packages/raystack/components/dialog/dialog.module.css @@ -124,3 +124,11 @@ .footer { padding: var(--rs-space-5) var(--rs-space-7); } + +/* Reduced transparency: drop the blur, compensate with a heavier solid scrim */ +@media (prefers-reduced-transparency: reduce) { + .overlayBlur { + backdrop-filter: none; + background-color: var(--rs-color-overlay-black-a9); + } +} diff --git a/packages/raystack/components/drawer/drawer.module.css b/packages/raystack/components/drawer/drawer.module.css index ba436ea52..9ca413bec 100644 --- a/packages/raystack/components/drawer/drawer.module.css +++ b/packages/raystack/components/drawer/drawer.module.css @@ -18,7 +18,7 @@ } .backdrop[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } .viewport { @@ -59,14 +59,21 @@ overscroll-behavior: contain; touch-action: auto; will-change: transform; + transition: opacity var(--rs-duration-drawer) var(--rs-ease-drawer); } .drawerPopup:focus { outline: none; } +.drawerPopup[data-starting-style], +.drawerPopup[data-ending-style] { + opacity: 0; +} + .drawerPopup[data-swiping] { user-select: none; + transition: none; } /* Right side drawer */ @@ -76,13 +83,8 @@ transform: translateX(var(--drawer-swipe-movement-x)); } -.drawerPopup-right[data-starting-style], -.drawerPopup-right[data-ending-style] { - transform: translateX(100%); -} - .drawerPopup-right[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } /* Left side drawer */ @@ -92,13 +94,8 @@ transform: translateX(var(--drawer-swipe-movement-x)); } -.drawerPopup-left[data-starting-style], .drawerPopup-left[data-ending-style] { - transform: translateX(-100%); -} - -.drawerPopup-left[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } /* Top side drawer */ @@ -108,13 +105,8 @@ transform: translateY(var(--drawer-swipe-movement-y)); } -.drawerPopup-top[data-starting-style], .drawerPopup-top[data-ending-style] { - transform: translateY(-100%); -} - -.drawerPopup-top[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } /* Bottom side drawer */ @@ -124,13 +116,8 @@ transform: translateY(var(--drawer-swipe-movement-y)); } -.drawerPopup-bottom[data-starting-style], .drawerPopup-bottom[data-ending-style] { - transform: translateY(100%); -} - -.drawerPopup-bottom[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } .content { @@ -157,6 +144,11 @@ color: var(--rs-color-foreground-base-primary); } +.close:active { + background-color: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + .close:focus-visible { outline: 2px solid var(--rs-color-border-base-focus); outline-offset: -1px; @@ -202,6 +194,28 @@ .drawerPopup-left, .drawerPopup-top, .drawerPopup-bottom { - transition: transform var(--rs-duration-drawer) var(--rs-ease-drawer); + transition: + transform var(--rs-duration-drawer) var(--rs-ease-drawer), + opacity var(--rs-duration-drawer) var(--rs-ease-drawer); + } + + .drawerPopup-right[data-starting-style], + .drawerPopup-right[data-ending-style] { + transform: translateX(100%); + } + + .drawerPopup-left[data-starting-style], + .drawerPopup-left[data-ending-style] { + transform: translateX(-100%); + } + + .drawerPopup-top[data-starting-style], + .drawerPopup-top[data-ending-style] { + transform: translateY(-100%); + } + + .drawerPopup-bottom[data-starting-style], + .drawerPopup-bottom[data-ending-style] { + transform: translateY(100%); } } diff --git a/packages/raystack/components/field/__tests__/field.test.tsx b/packages/raystack/components/field/__tests__/field.test.tsx index d4cfab9ea..6ba7cc7a4 100644 --- a/packages/raystack/components/field/__tests__/field.test.tsx +++ b/packages/raystack/components/field/__tests__/field.test.tsx @@ -72,13 +72,13 @@ describe('Field', () => { expect(screen.getByText('Enter a valid email')).toBeInTheDocument(); }); - it('does not render description when error is present', () => { + it('keeps description rendered alongside error', () => { render( content ); - expect(screen.queryByText('Enter email')).not.toBeInTheDocument(); + expect(screen.getByText('Enter email')).toBeInTheDocument(); expect(screen.getByText('Invalid email')).toBeInTheDocument(); }); }); diff --git a/packages/raystack/components/field/field-root.tsx b/packages/raystack/components/field/field-root.tsx index cf50d97da..d382cb4e4 100644 --- a/packages/raystack/components/field/field-root.tsx +++ b/packages/raystack/components/field/field-root.tsx @@ -47,11 +47,13 @@ export function FieldRoot({ {label && {label}}
{children}
{error && ( - - {error} - +
+ + {error} + +
)} - {!error && description && ( + {description && ( {description} diff --git a/packages/raystack/components/field/field.module.css b/packages/raystack/components/field/field.module.css index 39fee8da2..fffc4cb78 100644 --- a/packages/raystack/components/field/field.module.css +++ b/packages/raystack/components/field/field.module.css @@ -28,6 +28,32 @@ margin: 0; } +.errorSlot { + display: grid; + grid-template-rows: 1fr; + opacity: 1; +} + +.errorSlot > * { + overflow: hidden; + min-height: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .errorSlot { + transition: + grid-template-rows var(--rs-duration-fast) var(--rs-ease-out), + opacity var(--rs-duration-fast) var(--rs-ease-out); + } + + @starting-style { + .errorSlot { + grid-template-rows: 0fr; + opacity: 0; + } + } +} + .input { width: 100%; padding: 0 var(--rs-space-3); diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index 5ba007416..87c42b4b9 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -93,10 +93,15 @@ background: var(--rs-color-background-base-primary-hover); } +.removeIconContainer:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: -2px; +} + .removeIcon { cursor: pointer; color: var(--rs-color-foreground-base-primary); - transition: color 0.2s ease-in-out; + transition: color var(--rs-duration-normal) var(--rs-ease-out); width: var(--rs-space-4); height: var(--rs-space-4); } diff --git a/packages/raystack/components/floating-actions/floating-actions.module.css b/packages/raystack/components/floating-actions/floating-actions.module.css index 3d39ad781..f6d26ebb4 100644 --- a/packages/raystack/components/floating-actions/floating-actions.module.css +++ b/packages/raystack/components/floating-actions/floating-actions.module.css @@ -8,6 +8,31 @@ background: var(--rs-color-background-base-primary); border-radius: var(--rs-radius-2); box-shadow: var(--rs-shadow-lifted); + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); +} + +@starting-style { + .root { + opacity: 0; + } +} + +@media (prefers-reduced-motion: no-preference) { + .root { + transition: + opacity var(--rs-duration-normal) var(--rs-ease-out), + transform var(--rs-duration-normal) var(--rs-ease-out); + } + + @starting-style { + .root[data-variant="floating"][data-side="bottom"] { + transform: translateY(var(--rs-space-3)); + } + + .root[data-variant="floating"][data-side="top"] { + transform: translateY(calc(-1 * var(--rs-space-3))); + } + } } /* ===== Floating variant ===== */ @@ -31,8 +56,9 @@ } .root[data-variant="floating"][data-align="center"] { - left: 50%; - transform: translateX(-50%); + left: 0; + right: 0; + margin-inline: auto; } .root[data-variant="floating"][data-align="end"] { diff --git a/packages/raystack/components/headline/headline.module.css b/packages/raystack/components/headline/headline.module.css index 0d7b9f809..a71f2ccf0 100644 --- a/packages/raystack/components/headline/headline.module.css +++ b/packages/raystack/components/headline/headline.module.css @@ -5,7 +5,6 @@ font-family: var(--rs-font-title); font-weight: var(--rs-font-weight-regular); color: var(--rs-color-foreground-base-primary); - width: 100%; } .headline-t1 { diff --git a/packages/raystack/components/icon-button/icon-button.module.css b/packages/raystack/components/icon-button/icon-button.module.css index 2f45dcdf6..26133e3bb 100644 --- a/packages/raystack/components/icon-button/icon-button.module.css +++ b/packages/raystack/components/icon-button/icon-button.module.css @@ -12,17 +12,30 @@ padding: var(--rs-space-1); } -.iconButton:hover:not(:disabled), -.iconButton:active:not(:disabled) { +.iconButton:hover:not(:disabled) { background-color: var(--rs-color-background-base-primary-hover); color: var(--rs-color-foreground-base-primary); } +.iconButton:active:not(:disabled) { + background-color: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + +.iconButton:active:not(:disabled) { + transition: var(--rs-transition-pressed); +} + .iconButton:disabled { opacity: 0.5; cursor: not-allowed; } +.iconButton:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + .iconButton-size-1 { width: var(--rs-space-4); height: var(--rs-space-4); @@ -33,6 +46,25 @@ height: var(--rs-space-5); } +/* Expand sub-24px hit targets to 24px (WCAG 2.5.8); visible size unchanged. + size-1: (24 − 12) / 2 = 6px; size-2: (24 − 16) / 2 = 4px. */ +.iconButton-size-1, +.iconButton-size-2 { + position: relative; +} + +.iconButton-size-1::before { + content: ""; + position: absolute; + inset: -6px; +} + +.iconButton-size-2::before { + content: ""; + position: absolute; + inset: -4px; +} + .iconButton-size-3 { width: var(--rs-space-6); height: var(--rs-space-6); diff --git a/packages/raystack/components/image/__tests__/image.test.tsx b/packages/raystack/components/image/__tests__/image.test.tsx index 5c95b994c..92d17a448 100644 --- a/packages/raystack/components/image/__tests__/image.test.tsx +++ b/packages/raystack/components/image/__tests__/image.test.tsx @@ -124,6 +124,18 @@ describe('Image', () => { fireEvent.error(img); expect(img.src).toContain('/fallback.jpg'); }); + + it('assigns the fallback src exactly once, even if the fallback also errors', () => { + render(Test); + const img = screen.getByRole('img') as HTMLImageElement; + + fireEvent.error(img); + expect(img.src).toContain('/fallback.jpg'); + + // The fallback itself fails to load — must not re-assign it again. + fireEvent.error(img); + expect(img.src).toContain('/fallback.jpg'); + }); }); describe('Loading Attributes', () => { diff --git a/packages/raystack/components/image/image.module.css b/packages/raystack/components/image/image.module.css index fea2c7db5..97e4ec318 100644 --- a/packages/raystack/components/image/image.module.css +++ b/packages/raystack/components/image/image.module.css @@ -31,3 +31,19 @@ .image-radius-full { border-radius: var(--rs-radius-full); } + +/* Load fade: hidden only after JS confirms an in-flight load, so SSR/no-JS + images are never invisible. Cached images skip this entirely. */ +.image-loading { + opacity: 0; +} + +.image-loaded { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .image-loaded { + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} diff --git a/packages/raystack/components/image/image.tsx b/packages/raystack/components/image/image.tsx index 9bae64407..012ddf496 100644 --- a/packages/raystack/components/image/image.tsx +++ b/packages/raystack/components/image/image.tsx @@ -1,7 +1,13 @@ 'use client'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { ComponentProps, SyntheticEvent } from 'react'; +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { + ComponentProps, + SyntheticEvent, + useLayoutEffect, + useRef, + useState +} from 'react'; import styles from './image.module.css'; @@ -36,6 +42,8 @@ export function Image({ radius, fallback, onError, + onLoad, + src, width, height, style, @@ -43,9 +51,33 @@ export function Image({ decoding = 'async', ...props }: ImageProps) { + const imgRef = useRef(null); + const hasFallenBackRef = useRef(false); + const [loadState, setLoadState] = useState<'static' | 'loading' | 'loaded'>( + 'static' + ); + + useLayoutEffect(() => { + hasFallenBackRef.current = false; + const node = imgRef.current; + // Already-decoded (cached/SSR-painted) images stay visible — no fade. + setLoadState(node && !node.complete ? 'loading' : 'static'); + }, [src]); + + const handleLoad = (event: SyntheticEvent) => { + setLoadState(prev => (prev === 'loading' ? 'loaded' : prev)); + onLoad?.(event); + }; + const handleError = (event: SyntheticEvent) => { - if (fallback) { + if (fallback && !hasFallenBackRef.current) { + // One-shot: if the fallback itself errors, never re-assign it. + hasFallenBackRef.current = true; event.currentTarget.src = fallback; + } else { + // No fallback (or the fallback failed): show the alt/broken rendering + // instead of holding the img invisible. + setLoadState('static'); } onError?.(event); }; @@ -58,9 +90,20 @@ export function Image({ return ( {alt} - + {children} ); diff --git a/packages/raystack/components/meter/meter.module.css b/packages/raystack/components/meter/meter.module.css index 6e509c1c6..cf7ea59e1 100644 --- a/packages/raystack/components/meter/meter.module.css +++ b/packages/raystack/components/meter/meter.module.css @@ -42,6 +42,8 @@ .indicator { height: 100%; background-color: var(--rs-color-background-accent-emphasis); + transform: scaleX(calc(var(--rs-meter-percentage, 0) / 100)); + transform-origin: left; } /* Circular variant — viewBox is 72×72, SVG scales to container size */ @@ -86,7 +88,7 @@ @media (prefers-reduced-motion: no-preference) { .indicator { - transition: width var(--rs-duration-moderate) linear; + transition: transform var(--rs-duration-moderate) linear; } .circularIndicatorCircle { diff --git a/packages/raystack/components/navbar/navbar.module.css b/packages/raystack/components/navbar/navbar.module.css index 6c513efad..940d8fba7 100644 --- a/packages/raystack/components/navbar/navbar.module.css +++ b/packages/raystack/components/navbar/navbar.module.css @@ -11,7 +11,7 @@ @media (prefers-reduced-motion: no-preference) { .root { - transition: transform 0.2s ease-in-out; + transition: transform var(--rs-duration-normal) var(--rs-ease-in-out); } } @@ -47,3 +47,18 @@ grid-column: 3; justify-self: end; } + +/* Reduced motion: hide-on-scroll degrades to a quick fade instead of a slide */ +@media (prefers-reduced-motion: reduce) { + .root { + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + visibility var(--rs-duration-fast) var(--rs-ease-out); + } + + .root[data-hidden="true"] { + transform: none; + opacity: 0; + visibility: hidden; + } +} diff --git a/packages/raystack/components/number-field/number-field.module.css b/packages/raystack/components/number-field/number-field.module.css index a612349be..9664d6ddc 100644 --- a/packages/raystack/components/number-field/number-field.module.css +++ b/packages/raystack/components/number-field/number-field.module.css @@ -23,6 +23,7 @@ color: var(--rs-color-foreground-base-primary); cursor: pointer; padding: 0; + transition: var(--rs-transition-interactive); } .decrement { @@ -38,6 +39,17 @@ background: var(--rs-color-background-base-primary-hover); } +.decrement:focus-visible, +.increment:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: -2px; +} + +.decrement:active:not([data-disabled]), +.increment:active:not([data-disabled]) { + background: var(--rs-color-background-neutral-secondary); +} + .decrement[data-disabled], .increment[data-disabled] { pointer-events: none; @@ -59,10 +71,25 @@ color: var(--rs-color-foreground-base-primary); text-align: center; font-variant-numeric: tabular-nums; + transition: var(--rs-transition-interactive); outline: none; padding: 0 var(--rs-space-2); } +.input:hover { + border-color: var(--rs-color-border-base-focus); + background: var(--rs-color-background-base-primary-hover); +} + +.input:focus { + border-color: var(--rs-color-border-accent-emphasis); + background-color: var(--rs-color-background-base-primary); +} + +.input[data-invalid] { + border-color: var(--rs-color-border-danger-primary); +} + .input[data-disabled] { pointer-events: none; opacity: 0.5; diff --git a/packages/raystack/components/otp-field/otp-field.module.css b/packages/raystack/components/otp-field/otp-field.module.css index 0718b6e94..494c67de4 100644 --- a/packages/raystack/components/otp-field/otp-field.module.css +++ b/packages/raystack/components/otp-field/otp-field.module.css @@ -46,6 +46,7 @@ .otp-field-input:focus:not([data-readonly]):not([data-invalid]) { border-color: var(--rs-color-border-accent-emphasis); + box-shadow: 0 0 0 2px var(--rs-color-border-accent-primary); } .otp-field-input[data-invalid] { @@ -59,6 +60,10 @@ border-color: var(--rs-color-border-danger-emphasis-hover); } +.otp-field-input[data-invalid]:focus:not([data-readonly]) { + box-shadow: 0 0 0 2px var(--rs-color-border-danger-primary); +} + .otp-field-input[data-disabled], .otp-field-input:disabled { opacity: 0.5; diff --git a/packages/raystack/components/popover/popover.module.css b/packages/raystack/components/popover/popover.module.css index 89d75e9f8..88002b498 100644 --- a/packages/raystack/components/popover/popover.module.css +++ b/packages/raystack/components/popover/popover.module.css @@ -13,7 +13,7 @@ padding: var(--rs-space-3); background-color: var(--rs-color-background-base-primary); border-radius: var(--rs-radius-2); - box-shadow: var(--rs-shadow-soft); + box-shadow: var(--rs-shadow-lifted); border: 1px solid var(--rs-color-border-base-primary); color: var(--rs-color-foreground-base-primary); transform-origin: var(--transform-origin); diff --git a/packages/raystack/components/preview-card/preview-card.module.css b/packages/raystack/components/preview-card/preview-card.module.css index 4c74b3755..3e94df13e 100644 --- a/packages/raystack/components/preview-card/preview-card.module.css +++ b/packages/raystack/components/preview-card/preview-card.module.css @@ -103,17 +103,34 @@ } @media (prefers-reduced-motion: no-preference) { + /* Default mode (no PreviewCard.Viewport): Base UI positions the positioner + via transform (floating-ui floatingStyles). Compositor-only. */ .positioner { + transition: + transform var(--animation-duration) var(--easing), + opacity var(--animation-duration) var(--easing); + } + + .popup { + transition: + opacity var(--animation-duration) var(--easing), + transform var(--animation-duration) var(--easing); + } + + /* Viewport-morph mode: Base UI switches to inset positioning + (adaptiveOrigin) and drives --popup-width/--popup-height, so animating + inset + width/height IS the morph. Layout cost is confined to this popup + subtree for the duration of the morph. */ + .positioner:has(.viewport) { transition: top var(--animation-duration) var(--easing), left var(--animation-duration) var(--easing), right var(--animation-duration) var(--easing), bottom var(--animation-duration) var(--easing), - transform var(--animation-duration) var(--easing), opacity var(--animation-duration) var(--easing); } - .popup { + .popup:has(.viewport) { transition: width var(--animation-duration) var(--easing), height var(--animation-duration) var(--easing), diff --git a/packages/raystack/components/progress/progress-track.tsx b/packages/raystack/components/progress/progress-track.tsx index 2f1e63af1..576d6bd5c 100644 --- a/packages/raystack/components/progress/progress-track.tsx +++ b/packages/raystack/components/progress/progress-track.tsx @@ -35,7 +35,10 @@ export function ProgressTrack({ return ( - + {children} ); diff --git a/packages/raystack/components/progress/progress.module.css b/packages/raystack/components/progress/progress.module.css index 868daefb4..05fd4ec80 100644 --- a/packages/raystack/components/progress/progress.module.css +++ b/packages/raystack/components/progress/progress.module.css @@ -42,6 +42,8 @@ .indicator { height: 100%; background-color: var(--rs-color-background-accent-emphasis); + transform: scaleX(calc(var(--rs-progress-percentage, 0) / 100)); + transform-origin: left; } /* Circular variant — viewBox is 72×72, SVG scales to container size */ @@ -86,7 +88,7 @@ @media (prefers-reduced-motion: no-preference) { .indicator { - transition: width var(--rs-duration-moderate) linear; + transition: transform var(--rs-duration-moderate) linear; } .circularIndicatorCircle { @@ -103,3 +105,50 @@ text-align: center; white-space: nowrap; } + +/* Indeterminate (value={null}). Static fallback: dimmed centered bar / partial arc. */ +.indicator[data-indeterminate] { + transform: scaleX(0.4); + transform-origin: center; + opacity: 0.6; +} + +.circularSvg[data-indeterminate] .circularIndicatorCircle { + stroke-dashoffset: calc(var(--rs-progress-circumference) * 0.75); + opacity: 0.6; +} + +@media (prefers-reduced-motion: no-preference) { + .indicator[data-indeterminate] { + opacity: 1; + transform-origin: left; + transition: none; + animation: progress-indeterminate-sweep 1.2s var(--rs-ease-in-out) infinite; + } + + .circularSvg[data-indeterminate] { + animation: progress-indeterminate-rotate 1.2s linear infinite; + } + + .circularSvg[data-indeterminate] .circularIndicatorCircle { + opacity: 1; + } +} + +@keyframes progress-indeterminate-sweep { + from { + transform: translateX(-45%) scaleX(0.4); + } + to { + transform: translateX(105%) scaleX(0.4); + } +} + +@keyframes progress-indeterminate-rotate { + from { + transform: rotate(-90deg); + } + to { + transform: rotate(270deg); + } +} diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index 53562f1c5..634d236da 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -20,7 +20,9 @@ justify-content: center; cursor: pointer; box-sizing: border-box; - transition: background-color 150ms ease, border-color 150ms ease; + transition: + background-color var(--rs-duration-fast) var(--rs-ease-out), + border-color var(--rs-duration-fast) var(--rs-ease-out); } /* Group-level size — low specificity via :where() so item-level size wins */ @@ -40,11 +42,43 @@ min-height: var(--rs-space-4); } +/* Expand the small radio's 12px hit target to 24px (WCAG 2.5.8). + (24px − 12px) / 2 = 6px per side. Visible size is unchanged. */ +:where(.group-small) .radioitem, +.radioitem.small { + position: relative; +} + +:where(.group-small) .radioitem::before, +.radioitem.small::before { + content: ""; + position: absolute; + inset: -6px; +} + .radioitem:hover { border-color: var(--rs-color-border-base-tertiary-hover); background: var(--rs-color-background-base-primary-hover); } +.radioitem:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + +.radioitem:active:not([data-disabled]) { + transform: scale(0.92); +} + +@media (prefers-reduced-motion: no-preference) { + .radioitem { + transition: + background-color var(--rs-duration-fast) var(--rs-ease-out), + border-color var(--rs-duration-fast) var(--rs-ease-out), + transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + .radioitem[data-checked] { border: 1px solid var(--rs-color-background-accent-emphasis); background: var(--rs-color-background-accent-emphasis); diff --git a/packages/raystack/components/scroll-area/scroll-area.module.css b/packages/raystack/components/scroll-area/scroll-area.module.css index 10395305f..8c40cccac 100644 --- a/packages/raystack/components/scroll-area/scroll-area.module.css +++ b/packages/raystack/components/scroll-area/scroll-area.module.css @@ -1,6 +1,6 @@ :root { - --rs-scrollbar-size-default: var(--rs-space-2); - --rs-scrollbar-size-hover: 6px; + --rs-scrollbar-size-default: var(--rs-space-2); /* 4px — resting thumb size */ + --rs-scrollbar-size-hover: 6px; /* expanded thumb; also the constant hit area */ } .root { @@ -25,23 +25,17 @@ background: transparent; pointer-events: auto; position: relative; - transition: opacity 150ms ease-out; + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); } +/* Constant size = constant hit area; the track is transparent so the extra + 2px of gutter is invisible. Hover no longer changes layout. */ .scrollbar[data-orientation="vertical"] { - width: var(--rs-scrollbar-size-default); -} - -.scrollbar[data-orientation="vertical"]:hover { width: var(--rs-scrollbar-size-hover); } .scrollbar[data-orientation="horizontal"] { flex-direction: column; - height: var(--rs-scrollbar-size-default); -} - -.scrollbar[data-orientation="horizontal"]:hover { height: var(--rs-scrollbar-size-hover); } @@ -49,18 +43,45 @@ flex: 1; background: var(--rs-color-overlay-base-a5); border-radius: var(--rs-radius-2); - transition: opacity 150ms ease-out; + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + background-color var(--rs-duration-fast) var(--rs-ease-out); opacity: 1; pointer-events: auto; touch-action: none; } +/* Resting: thumb visually --rs-scrollbar-size-default (4px) inside the 6px + gutter — 0.667 = 4px / 6px. Anchored to the outer edge, grows inward. */ +.scrollbar[data-orientation="vertical"] .thumb { + transform: scaleX(0.667); + transform-origin: 100% 50%; +} + +.scrollbar[data-orientation="horizontal"] .thumb { + transform: scaleY(0.667); + transform-origin: 50% 100%; +} + +/* Hover/drag: full size, one overlay step deeper per state. Base UI exposes + no data-pressed; pointer capture keeps :active held for the whole drag. */ +.scrollbar:hover .thumb { + transform: none; + background: var(--rs-color-overlay-base-a6); +} + +.thumb:active, +.scrollbar:active .thumb { + transform: none; + background: var(--rs-color-overlay-base-a7); +} + @media (prefers-reduced-motion: no-preference) { - .scrollbar { + .thumb { transition: - width 150ms ease-out, - height 150ms ease-out, - opacity 150ms ease-out; + opacity var(--rs-duration-fast) var(--rs-ease-out), + background-color var(--rs-duration-fast) var(--rs-ease-out), + transform var(--rs-duration-fast) var(--rs-ease-out); } } diff --git a/packages/raystack/components/select/select-item.tsx b/packages/raystack/components/select/select-item.tsx index 9a7c52797..140e80994 100644 --- a/packages/raystack/components/select/select-item.tsx +++ b/packages/raystack/components/select/select-item.tsx @@ -6,12 +6,28 @@ import { } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ReactNode, useLayoutEffect } from 'react'; -import { Checkbox } from '../checkbox'; import { getMatch } from '../menu/utils'; import { Text } from '../text'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; +const CheckMarkIcon = () => ( + + + +); + export interface SelectItemProps extends SelectPrimitive.Item.Props { leadingIcon?: ReactNode; } @@ -76,7 +92,15 @@ export function SelectItem({ {...props} render={(renderProps, state) => (
- {multiple && } + {multiple && ( + + )} {element}
)} diff --git a/packages/raystack/components/select/select.module.css b/packages/raystack/components/select/select.module.css index 8fb9297cd..911df9c8b 100644 --- a/packages/raystack/components/select/select.module.css +++ b/packages/raystack/components/select/select.module.css @@ -1,6 +1,5 @@ .positioner { z-index: var(--rs-z-index-portal); - background-color: var(--rs-color-background-base-primary); } .content { @@ -66,6 +65,30 @@ pointer-events: none; } +.checkIndicator { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--rs-space-5); + height: var(--rs-space-5); + border-radius: var(--rs-radius-1); + background: var(--rs-color-background-base-primary); + border: 1px solid var(--rs-color-border-base-secondary); + color: var(--rs-color-foreground-accent-emphasis); +} + +.checkIndicator[data-checked] { + background: var(--rs-color-background-accent-emphasis); + border: none; +} + +.checkIndicator svg { + width: 100%; + height: 100%; +} + .label { padding: var(--rs-space-2) var(--rs-space-3); font-weight: var(--rs-font-weight-medium); @@ -153,6 +176,10 @@ color: var(--rs-color-foreground-base-primary); } +.trigger:active:not([data-disabled]):not(:disabled) { + background-color: var(--rs-color-background-neutral-secondary); +} + .trigger:focus-visible { outline: 1px solid var(--rs-color-border-accent-emphasis); } @@ -181,6 +208,16 @@ color: var(--rs-color-foreground-base-secondary); } +.trigger[data-popup-open] .triggerIcon { + transform: rotate(180deg); +} + +@media (prefers-reduced-motion: no-preference) { + .triggerIcon { + transition: transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + .content[data-variant="text"] { margin-top: var(--rs-space-2); padding: var(--rs-space-1); diff --git a/packages/raystack/components/side-panel/side-panel.module.css b/packages/raystack/components/side-panel/side-panel.module.css index f4526ee63..1fd61cc17 100644 --- a/packages/raystack/components/side-panel/side-panel.module.css +++ b/packages/raystack/components/side-panel/side-panel.module.css @@ -1,7 +1,7 @@ .side-panel { width: 400px; background: var(--rs-color-background-base-primary); - overflow: scroll; + overflow: auto; } .side-panel-left { diff --git a/packages/raystack/components/side-panel/side-panel.tsx b/packages/raystack/components/side-panel/side-panel.tsx index 27d904863..1cf6050aa 100644 --- a/packages/raystack/components/side-panel/side-panel.tsx +++ b/packages/raystack/components/side-panel/side-panel.tsx @@ -20,6 +20,12 @@ interface SidePanelProps extends ComponentProps<'aside'>, VariantProps {} +/** + * SidePanel is persistent layout chrome (like Sidebar), not an overlay: + * it intentionally has no enter/exit animation. If you conditionally mount + * it, it will appear/disappear instantly — prefer keeping it mounted, or use + * Drawer/Dialog for transient surfaces. + */ const SidePanelRoot = ({ side = 'right', className, diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index 676cc412c..fb58815ff 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -61,8 +61,18 @@ align-items: flex-start; } +.root [data-collapse-hidden] { + transition: + opacity 0.2s ease, + visibility 0.2s ease; +} + .root[data-closed] [data-collapse-hidden] { - display: none; + width: 0; + min-width: 0; + overflow: hidden; + opacity: 0; + visibility: hidden; } .footer { @@ -82,12 +92,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; text-decoration: none; - transition: - background-color 0.2s ease, - color 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease, - opacity 0.2s ease; + transition: var(--rs-transition-interactive); box-sizing: border-box; } @@ -113,6 +118,11 @@ color: var(--rs-color-foreground-base-primary); } +.nav-item:active:not([data-disabled="true"]) { + background-color: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + /* keep item highlighted when menu is open */ .more-trigger[aria-expanded="true"] { background-color: var(--rs-color-background-base-primary-hover); @@ -201,13 +211,16 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - transition: opacity 0.2s ease; + transition: + opacity var(--rs-duration-normal) var(--rs-ease-out), + visibility var(--rs-duration-normal) var(--rs-ease-out); } .root[data-closed] .nav-text { width: 0; + min-width: 0; opacity: 0; - display: none; + visibility: hidden; } .resizeHandle { @@ -216,8 +229,8 @@ right: 0; width: var(--rs-space-2); height: 100%; - cursor: ew-resize; - transition: background-color 0.2s ease; + cursor: pointer; + transition: background-color var(--rs-duration-normal) var(--rs-ease-out); transform: translateX(50%); } @@ -232,7 +245,6 @@ .root[data-position="right"] .resizeHandle { left: 0; right: auto; - cursor: ew-resize; transform: translateX(-50%); } @@ -304,7 +316,7 @@ height: var(--rs-space-4); color: var(--rs-color-foreground-base-secondary); transform: rotate(-90deg); - transition: transform 0.2s ease; + transition: transform var(--rs-duration-normal) var(--rs-ease-out); flex-shrink: 0; } @@ -319,7 +331,7 @@ padding: var(--rs-space-2) var(--rs-space-3); color: var(--rs-color-foreground-base-secondary); opacity: 0; - transition: opacity 0.2s ease; + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); } .nav-group-header:hover .nav-group-trailing-icon { @@ -380,10 +392,7 @@ } @media (prefers-reduced-motion: no-preference) { - .root, - .nav-item, - .nav-text, - .resizeHandle { + .root { transition: width 0.2s ease; } diff --git a/packages/raystack/components/slider/slider.module.css b/packages/raystack/components/slider/slider.module.css index 40b98b19b..bbdd714f1 100644 --- a/packages/raystack/components/slider/slider.module.css +++ b/packages/raystack/components/slider/slider.module.css @@ -53,8 +53,10 @@ outline: none; } -.thumb:hover { - border-color: var(--rs-color-border-accent-emphasis-hover); +.thumb:focus-visible .thumbLarge, +.thumb:focus-visible .thumbSmall { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; } .thumbLarge { @@ -87,11 +89,45 @@ box-shadow: var(--rs-shadow-soft); } +/* Expand the small thumb's 8×16px hit target to 24×24px (WCAG 2.5.8). + Horizontal: (24 − 8) / 2 = 8px; vertical: (24 − 16) / 2 = 4px. + Pointer events bubble from the pseudo through .thumbSmall to the + Base UI thumb element, so grabbing works from the expanded area. */ +.thumbSmall { + position: relative; +} + +.thumbSmall::before { + content: ""; + position: absolute; + top: -4px; + bottom: -4px; + left: -8px; + right: -8px; +} + .thumb:hover .thumbLarge, .thumb:hover .thumbSmall { background-color: var(--rs-color-background-base-secondary); } +/* Grabbing: the thumb grows slightly and lifts. Scale lives on the child + so the thumb's track position (set inline by Base UI) is never animated. */ +.thumb:active .thumbLarge, +.thumb:active .thumbSmall { + transform: scale(1.08); + box-shadow: var(--rs-shadow-lifted); +} + +@media (prefers-reduced-motion: no-preference) { + .thumbLarge, + .thumbSmall { + transition: + transform var(--rs-duration-fast) var(--rs-ease-out), + box-shadow var(--rs-duration-fast) var(--rs-ease-out); + } +} + .label { position: absolute; top: calc(-1 * (var(--rs-space-8) + 1px)); diff --git a/packages/raystack/components/spinner/spinner.module.css b/packages/raystack/components/spinner/spinner.module.css index 029907e55..13c2a3172 100644 --- a/packages/raystack/components/spinner/spinner.module.css +++ b/packages/raystack/components/spinner/spinner.module.css @@ -16,41 +16,49 @@ .pole:nth-child(1) { transform: rotate(0deg); animation-delay: 0s; + opacity: 0.8; } .pole:nth-child(2) { transform: rotate(-45deg); - animation-delay: -0.15s; + animation-delay: -0.1125s; + opacity: 0.7; } .pole:nth-child(3) { transform: rotate(-90deg); - animation-delay: -0.3s; + animation-delay: -0.225s; + opacity: 0.6; } .pole:nth-child(4) { transform: rotate(-135deg); - animation-delay: -0.45s; + animation-delay: -0.3375s; + opacity: 0.5; } .pole:nth-child(5) { transform: rotate(-180deg); - animation-delay: -0.6s; + animation-delay: -0.45s; + opacity: 0.4; } .pole:nth-child(6) { transform: rotate(-225deg); - animation-delay: -0.75s; + animation-delay: -0.5625s; + opacity: 0.3; } .pole:nth-child(7) { transform: rotate(-270deg); - animation-delay: -0.9s; + animation-delay: -0.675s; + opacity: 0.2; } .pole:nth-child(8) { transform: rotate(-315deg); - animation-delay: -1.05s; + animation-delay: -0.7875s; + opacity: 0.1; } @keyframes spin { @@ -90,7 +98,7 @@ @media (prefers-reduced-motion: no-preference) { .pole { - animation: spin 1.2s linear infinite; + animation: spin 0.9s linear infinite; } } diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index 5a7e3853d..ba0dd61c5 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -9,7 +9,12 @@ cursor: pointer; background: var(--rs-color-background-neutral-secondary); border-radius: var(--rs-radius-full); - transition: background-color 200ms ease; + transition: background-color var(--rs-duration-normal) var(--rs-ease-out); +} + +.switch:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; } .switch.large { @@ -56,7 +61,7 @@ @media (prefers-reduced-motion: no-preference) { .thumb { - transition: transform 200ms ease-in-out; + transition: transform var(--rs-duration-normal) var(--rs-ease-in-out); } } @@ -74,3 +79,20 @@ .switch.small[data-checked] .thumb { transform: translateX(var(--rs-space-4)); } + +/* Pressed: the thumb stretches toward the track center (iOS behavior). + transform-origin only affects the scale; the translateX values must + exactly mirror the resting rules above. */ +.switch:active:not([data-disabled]) .thumb { + transform: translateX(var(--rs-space-1)) scaleX(1.15); + transform-origin: left center; +} + +.switch[data-checked]:active:not([data-disabled]) .thumb { + transform: translateX(var(--rs-space-5)) scaleX(1.15); + transform-origin: right center; +} + +.switch.small[data-checked]:active:not([data-disabled]) .thumb { + transform: translateX(var(--rs-space-4)) scaleX(1.15); +} diff --git a/packages/raystack/components/table/table.module.css b/packages/raystack/components/table/table.module.css index 93a463865..363dda9ff 100644 --- a/packages/raystack/components/table/table.module.css +++ b/packages/raystack/components/table/table.module.css @@ -66,3 +66,52 @@ line-height: var(--rs-line-height-small); letter-spacing: var(--rs-letter-spacing-small); } + +/* Row state hooks. `.row` is applied by Table.Row; visuals only kick in when + the consumer sets data-state="selected" or the `interactive` prop. Cells + inherit so the row color shows through the opaque .cell background. */ +.row { + background: var(--rs-color-background-base-primary); +} + +.row > .cell { + background: inherit; +} + +.row-interactive { + cursor: pointer; +} + +.row-interactive:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.row-interactive:active { + background: var(--rs-color-background-neutral-secondary); +} + +.row[data-state="selected"], +.row[data-state="selected"]:hover, +.row[data-state="selected"]:active { + background: var(--rs-color-background-accent-primary); +} + +/* Scrolled-under header edge: the hairline under sticky header cells is drawn + only once content has scrolled beneath them, fading in across the first + 24px of scroll. Tracks scroll 1:1 (state, not motion) so it applies under + reduced motion too. Browsers without scroll-driven animation support keep + the static border declared on .head. */ +@supports (animation-timeline: scroll()) { + .head { + border-bottom-color: transparent; + animation: header-edge linear both; + animation-timeline: scroll(nearest block); + animation-range: 0px 24px; + } +} + +@keyframes header-edge { + to { + border-bottom-color: var(--rs-color-border-base-primary); + } +} diff --git a/packages/raystack/components/table/table.tsx b/packages/raystack/components/table/table.tsx index a92065fd4..ded5b687e 100644 --- a/packages/raystack/components/table/table.tsx +++ b/packages/raystack/components/table/table.tsx @@ -25,8 +25,19 @@ function TableBody({ ...props }: ComponentProps<'tbody'>) { } TableBody.displayName = 'Table.Body'; -function TableRow({ ...props }: ComponentProps<'tr'>) { - return