diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 82f756aad..d10c346a7 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,43 @@ One section per release, newest first, with only the changes that need action from you. The full record of every release, features and fixes included, is on [GitHub releases](https://github.com/raystack/apsara/releases). +## Unreleased: Sidebar and Drawer use one `side` + +`Sidebar` names its edge with `side`, as other components do. `Drawer` takes +`side` on the root only. + +### 1. Rename Sidebar `position` to `side` + +| Before | After | +|---|---| +| `` | `` | +| `[data-position="right"]` on the sidebar element | `[data-side="right"]` | +| `useSidebar().position` | `useSidebar().side` | + +The `position` prop and `useSidebar().position` still work, and are +deprecated. The `data-position` attribute is gone, so update CSS that selects +it. + +### 2. Remove `side` from `Drawer.Content` + +`Drawer.Content` reads `side` from `Drawer`. Delete `side` from +`Drawer.Content`, and set it on `Drawer`. + +```tsx +// Before + + ... + + +// After + + ... + +``` + +If only `Drawer.Content` had `side`, the drawer now slides in from the right, +the default of `Drawer`. + ## Unreleased: date filters compare whole days `DataView`, `DataTable` and `FilterChip` read and compare dates as calendar diff --git a/apps/www/src/content/docs/components/drawer/demo.ts b/apps/www/src/content/docs/components/drawer/demo.ts index 360fd17c8..1980f733c 100644 --- a/apps/www/src/content/docs/components/drawer/demo.ts +++ b/apps/www/src/content/docs/components/drawer/demo.ts @@ -4,10 +4,11 @@ import type { ComponentPropsType } from '@/components/demo/types'; import { getPropsString } from '@/lib/utils'; export const getCode = (props: ComponentPropsType) => { + const { side, ...contentProps } = props; return ` - + }>Drawer - + Drawer A simple drawer @@ -56,7 +57,7 @@ export const positionDemo = { }>Top Drawer - + Top Drawer Slides in from the Top @@ -66,7 +67,7 @@ export const positionDemo = { }>Right Drawer - + Right Drawer Slides in from the Right @@ -76,7 +77,7 @@ export const positionDemo = { }>Left Drawer - + Left Drawer Slides in from the Left @@ -86,7 +87,7 @@ export const positionDemo = { }>Bottom Drawer - + Bottom Drawer Slides in from the Bottom diff --git a/apps/www/src/content/docs/components/drawer/index.mdx b/apps/www/src/content/docs/components/drawer/index.mdx index 4c11cf1c6..d388daf0f 100644 --- a/apps/www/src/content/docs/components/drawer/index.mdx +++ b/apps/www/src/content/docs/components/drawer/index.mdx @@ -41,7 +41,7 @@ A drawer slides in from the edge named by `side` and traps focus while open. ### Positioning -The Drawer can slide in from different sides of the screen. Swipe-to-dismiss is automatically configured based on the `side` prop. +Set `side` on the root. `Drawer.Content` slides in from that edge, and swipe-to-dismiss follows it. diff --git a/apps/www/src/content/docs/components/drawer/props.ts b/apps/www/src/content/docs/components/drawer/props.ts index bff6148de..2e1322004 100644 --- a/apps/www/src/content/docs/components/drawer/props.ts +++ b/apps/www/src/content/docs/components/drawer/props.ts @@ -1,5 +1,8 @@ export interface DrawerProps { - /** The direction from which the drawer appears and can be swiped to dismiss. */ + /** + * The edge the drawer slides in from. It also sets the swipe-to-dismiss direction. + * @default "right" + */ side?: 'top' | 'right' | 'bottom' | 'left'; /** Boolean to control the default open state. */ @@ -28,9 +31,6 @@ export interface DrawerProps { } export interface DrawerContentProps { - /** The direction from which the drawer appears. */ - side?: 'top' | 'right' | 'bottom' | 'left'; - /** Whether to show the close button. */ showCloseButton?: boolean; diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 2c2925bff..8ef3262a0 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -113,7 +113,7 @@ The button that triggers the Select. ### Content -The container that holds the Select items. +The container that holds the Select items. It passes the Base UI Positioner props, such as `collisionPadding` and `sticky`, to the positioner. diff --git a/apps/www/src/content/docs/components/select/props.ts b/apps/www/src/content/docs/components/select/props.ts index 80ddce754..5ab02897b 100644 --- a/apps/www/src/content/docs/components/select/props.ts +++ b/apps/www/src/content/docs/components/select/props.ts @@ -79,6 +79,49 @@ export interface SelectContentProps { */ sideOffset?: number; + /** + * Additional offset in pixels along the alignment axis. + * @default 0 + */ + alignOffset?: number; + + /** Space in pixels to keep from the edge of the collision boundary. */ + collisionPadding?: number; + + /** + * The element or area that confines the content. + * @default "clipping-ancestors" + */ + collisionBoundary?: Element | Element[] | 'clipping-ancestors'; + + /** How the content moves when it does not fit on the chosen side. */ + collisionAvoidance?: { + side?: 'flip' | 'shift' | 'none'; + align?: 'flip' | 'shift' | 'none'; + fallbackAxisSide?: 'start' | 'end' | 'none'; + }; + + /** + * Keeps the content in the viewport after the trigger scrolls out of view. + * @default false + */ + sticky?: boolean; + + /** + * The CSS `position` of the content. + * @default "absolute" + */ + positionMethod?: 'absolute' | 'fixed'; + + /** An element to place the content against, in place of the trigger. */ + anchor?: Element | React.RefObject | null; + + /** + * Stops the content from following layout shifts of the anchor. + * @default false + */ + disableAnchorTracking?: boolean; + /** Additional CSS class names. */ className?: string; diff --git a/apps/www/src/content/docs/components/sidebar/demo.ts b/apps/www/src/content/docs/components/sidebar/demo.ts index 4a638a6b5..bccd84fdb 100644 --- a/apps/www/src/content/docs/components/sidebar/demo.ts +++ b/apps/www/src/content/docs/components/sidebar/demo.ts @@ -66,13 +66,13 @@ export const preview = { style: styleDemo }; -export const positionDemo = { +export const sideDemo = { type: 'code', tabs: [ { name: 'Left', code: sidebarLayout(` - + @@ -97,7 +97,7 @@ export const positionDemo = { { name: 'Right', code: sidebarLayoutRight(` - + diff --git a/apps/www/src/content/docs/components/sidebar/index.mdx b/apps/www/src/content/docs/components/sidebar/index.mdx index 763870ae4..f34bbdc02 100644 --- a/apps/www/src/content/docs/components/sidebar/index.mdx +++ b/apps/www/src/content/docs/components/sidebar/index.mdx @@ -6,7 +6,7 @@ source: packages/react/components/sidebar import { preview, - positionDemo, + sideDemo, variantDemo, collapsibleGroupDemo, controlledGroupDemo, @@ -50,11 +50,11 @@ import { Sidebar } from "@raystack/apsara"; Most of a sidebar's behaviour is about collapsing: what it looks like collapsed, who controls it, and how the user gets it back. -### Position +### Side -`left` (the default) or `right`. Put it on the right only when the main content is the thing people arrive for and navigation is secondary. +Set `side` to `left` (the default) or `right`. Put it on the right only when the main content is the thing people arrive for and navigation is secondary. - + ### Variants @@ -266,7 +266,8 @@ It returns: - `open`: `true` when the sidebar is expanded - `setOpen(open)`: expand or collapse the sidebar - `collapsible`: mirrors the `collapsible` prop: `"icon"`, `"hidden"`, or `"none"` (`"none"` means it cannot be collapsed) -- `position`: `"left"` or `"right"` +- `side`: `"left"` or `"right"` +- `position`: deprecated alias of `side` - `hideItemTooltips`: whether the tooltips the Sidebar adds to items are disabled ```tsx @@ -313,7 +314,7 @@ The Sidebar implements the following accessibility features: - Truncation handling - Long item and group labels truncate with an ellipsis - - Items whose label is clipped show the full text in a tooltip on hover or focus (positioned away from the sidebar's edge based on `position`; disable with `hideItemTooltips`) + - Items whose label is clipped show the full text in a tooltip on hover or focus (positioned away from the sidebar's edge based on `side`; disable with `hideItemTooltips`) - Keyboard navigation support diff --git a/apps/www/src/content/docs/components/sidebar/props.ts b/apps/www/src/content/docs/components/sidebar/props.ts index 9c8fe01b8..aff937cc3 100644 --- a/apps/www/src/content/docs/components/sidebar/props.ts +++ b/apps/www/src/content/docs/components/sidebar/props.ts @@ -39,9 +39,12 @@ export interface SidebarRootProps { /** Called when a hover peek starts or ends. */ onPeekChange?: (isPeeking: boolean) => void; - /** Position of the Sidebar. + /** The edge of the layout the sidebar sits on. * @default "left" */ + side?: 'left' | 'right'; + + /** @deprecated Use `side`. */ position?: 'left' | 'right'; /** Visual style variant of the Sidebar. diff --git a/packages/react/components/drawer/__tests__/drawer.test.tsx b/packages/react/components/drawer/__tests__/drawer.test.tsx index ccd025528..da86390fb 100644 --- a/packages/react/components/drawer/__tests__/drawer.test.tsx +++ b/packages/react/components/drawer/__tests__/drawer.test.tsx @@ -22,7 +22,7 @@ const BasicDrawer = ({ } & Record) => ( }>{TRIGGER_TEXT} - + {DRAWER_TITLE} {DRAWER_DESCRIPTION} diff --git a/packages/react/components/drawer/drawer-content.tsx b/packages/react/components/drawer/drawer-content.tsx index d596bfac0..ed8a23ccf 100644 --- a/packages/react/components/drawer/drawer-content.tsx +++ b/packages/react/components/drawer/drawer-content.tsx @@ -1,13 +1,14 @@ 'use client'; import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'; -import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { cva, cx } from 'class-variance-authority'; import { ReactNode } from 'react'; import { XIcon } from '~/icons'; import { type Radius, radiusVariants } from '../../shared/radius'; import { IconButton } from '../icon-button'; import { useThemeInjection } from '../theme/portal'; import styles from './drawer.module.css'; +import { useDrawerSide } from './drawer-root'; const drawerPopup = cva(styles.drawerPopup, { variants: { @@ -18,15 +19,11 @@ const drawerPopup = cva(styles.drawerPopup, { left: styles['drawerPopup-left'], right: styles['drawerPopup-right'] } - }, - defaultVariants: { - side: 'right' } }); export interface DrawerContentProps - extends Omit, - VariantProps { + extends Omit { showCloseButton?: boolean; overlayProps?: DrawerPrimitive.Backdrop.Props; children?: ReactNode; @@ -42,7 +39,6 @@ export interface DrawerContentProps export function DrawerContent({ className, children, - side = 'right', showCloseButton = true, overlayProps, 'aria-label': ariaLabel, @@ -51,6 +47,7 @@ export function DrawerContent({ radius, ...props }: DrawerContentProps) { + const side = useDrawerSide(); const resolvedAriaLabel = ariaLabel ?? (ariaLabelledBy ? undefined : 'Drawer'); const theme = useThemeInjection(); diff --git a/packages/react/components/drawer/drawer-root.tsx b/packages/react/components/drawer/drawer-root.tsx index 1a19ec49d..c72f3883b 100644 --- a/packages/react/components/drawer/drawer-root.tsx +++ b/packages/react/components/drawer/drawer-root.tsx @@ -1,6 +1,7 @@ 'use client'; import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'; +import { createContext, useContext } from 'react'; type Side = 'top' | 'right' | 'bottom' | 'left'; @@ -14,8 +15,15 @@ const sideToSwipeDirection: Record< left: 'left' }; +const DrawerSideContext = createContext('right'); + +export const useDrawerSide = () => useContext(DrawerSideContext); + export interface DrawerRootProps extends DrawerPrimitive.Root.Props { - /** The direction from which the drawer appears and can be swiped to dismiss. */ + /** + * The edge the drawer slides in from. It also sets the swipe-to-dismiss direction. + * @default 'right' + */ side?: Side; } @@ -25,10 +33,12 @@ export function DrawerRoot({ ...props }: DrawerRootProps) { return ( - + + + ); } DrawerRoot.displayName = 'Drawer'; diff --git a/packages/react/components/select/__tests__/select.test.tsx b/packages/react/components/select/__tests__/select.test.tsx index 8349a4200..e9264be7f 100644 --- a/packages/react/components/select/__tests__/select.test.tsx +++ b/packages/react/components/select/__tests__/select.test.tsx @@ -80,6 +80,30 @@ describe('Select', () => { }); }); + describe('Positioning', () => { + it.each([ + false, + true + ])('forwards positioner props to the positioner (autocomplete: %s)', async autocomplete => { + render( + + ); + fireEvent.click(screen.getByLabelText('Select option')); + await flushMicrotasks(); + + expect( + document.querySelector('[data-slot="select-positioner"]') + ).toHaveStyle({ position: 'fixed' }); + }); + }); + describe('Single Selection', () => { it('displays selected value in trigger', async () => { render(); diff --git a/packages/react/components/select/select-content.tsx b/packages/react/components/select/select-content.tsx index 1b6e20a52..30170b643 100644 --- a/packages/react/components/select/select-content.tsx +++ b/packages/react/components/select/select-content.tsx @@ -25,22 +25,43 @@ export function SelectContent({ className, children, searchPlaceholder = 'Search...', - sideOffset = 4, + radius, side = 'bottom', + sideOffset = 4, align = 'start', - radius, + alignOffset, + anchor, + arrowPadding, + collisionAvoidance, + collisionBoundary, + collisionPadding, + disableAnchorTracking, + positionMethod, + sticky, ...props }: SelectContentProps) { const { autocomplete, multiple } = useSelectContext(); const theme = useThemeInjection(); + const positionerProps = { + side, + sideOffset, + align, + alignOffset, + anchor, + arrowPadding, + collisionAvoidance, + collisionBoundary, + collisionPadding, + disableAnchorTracking, + positionMethod, + sticky + }; if (autocomplete) { return ( @@ -76,9 +97,7 @@ export function SelectContent({ return ( ( @@ -38,7 +38,7 @@ const BasicSidebar = ({ open={open} onOpenChange={onOpenChange} collapsible={collapsible} - position={position} + side={side} {...props} > @@ -467,18 +467,29 @@ describe('Sidebar', () => { }); }); - describe('Position Variants', () => { - it('applies left position by default', () => { + describe('Side and Variants', () => { + it('applies left side by default', () => { const { container } = render(); - const sidebar = container.querySelector('[data-position="left"]'); + const sidebar = container.querySelector('[data-side="left"]'); expect(sidebar).toBeInTheDocument(); }); - it('applies right position when specified', () => { - const { container } = render(); + it('applies right side when specified', () => { + const { container } = render(); - const sidebar = container.querySelector('[data-position="right"]'); + const sidebar = container.querySelector('[data-side="right"]'); + expect(sidebar).toBeInTheDocument(); + }); + + it('reads the deprecated position prop as side', () => { + const { container } = render( + + + + ); + + const sidebar = container.querySelector('[data-side="right"]'); expect(sidebar).toBeInTheDocument(); }); @@ -681,9 +692,9 @@ describe('Sidebar', () => { expect(screen.getAllByText(HELP_ITEM_TEXT)).toHaveLength(1); }); - it('opens the clipped-label tooltip away from a right-positioned sidebar', async () => { + it('opens the clipped-label tooltip away from a right-side sidebar', async () => { const user = userEvent.setup(); - render(); + render(); const text = screen.getByText(HELP_ITEM_TEXT); Object.defineProperty(text, 'scrollWidth', { @@ -702,7 +713,7 @@ describe('Sidebar', () => { }); const tooltip = screen .getAllByText(HELP_ITEM_TEXT) - .map(el => el.closest('[data-side]')) + .map(el => el.closest('[data-slot="tooltip-content"]')) .find(Boolean); expect(tooltip).toHaveAttribute('data-side', 'left'); }); @@ -1126,11 +1137,11 @@ describe('Sidebar', () => { describe('useSidebar', () => { const CollapseStatus = () => { - const { isCollapsed, open, position, collapsible } = useSidebar(); + const { isCollapsed, open, side, collapsible } = useSidebar(); return (
{isCollapsed ? 'collapsed' : 'expanded'}:{open ? 'open' : 'closed'} @@ -1150,7 +1161,7 @@ describe('Sidebar', () => { const status = screen.getByTestId('collapse-status'); expect(status).toHaveTextContent('expanded:open'); - expect(status).toHaveAttribute('data-position', 'left'); + expect(status).toHaveAttribute('data-side', 'left'); expect(status).toHaveAttribute('data-collapsible', 'icon'); fireEvent.click(screen.getByTestId('sidebar-trigger')); diff --git a/packages/react/components/sidebar/sidebar-item.tsx b/packages/react/components/sidebar/sidebar-item.tsx index 88b5c591b..7dabacffe 100644 --- a/packages/react/components/sidebar/sidebar-item.tsx +++ b/packages/react/components/sidebar/sidebar-item.tsx @@ -43,13 +43,13 @@ export function SidebarItem({ render = , ...props }: SidebarItemProps) { - const { isCollapsed, position, hideItemTooltips } = useSidebarSafe(); + const { isCollapsed, side, hideItemTooltips } = useSidebarSafe(); const onPopupOpenChange = useContext(SidebarPopupContext); const insideSidebarMore = useInsideSidebarMore(); const textRef = useRef(null); const [tooltipOpen, setTooltipOpen] = useState(false); // Open away from the sidebar's edge, like the More menu and handle tooltip. - const tooltipSide = position === 'left' ? 'right' : 'left'; + const tooltipSide = side === 'left' ? 'right' : 'left'; // Report to the sidebar so an open tooltip holds a hover peek. An effect // (not the onOpenChange handler) so unmounting mid-tooltip still diff --git a/packages/react/components/sidebar/sidebar-more.tsx b/packages/react/components/sidebar/sidebar-more.tsx index 8f266bcab..cef071ebc 100644 --- a/packages/react/components/sidebar/sidebar-more.tsx +++ b/packages/react/components/sidebar/sidebar-more.tsx @@ -36,7 +36,7 @@ export function SidebarMore({ leadingIcon, classNames }: SidebarMoreProps) { - const { isCollapsed, position, hideItemTooltips } = useSidebarSafe(); + const { isCollapsed, side, hideItemTooltips } = useSidebarSafe(); const onPopupOpenChange = useContext(SidebarPopupContext); const [menuOpen, setMenuOpen] = useState(false); @@ -87,7 +87,7 @@ export function SidebarMore({ } /> {label} @@ -98,7 +98,7 @@ export function SidebarMore({ )} {children} diff --git a/packages/react/components/sidebar/sidebar-root.tsx b/packages/react/components/sidebar/sidebar-root.tsx index a94f5a1e5..9b94fe9ec 100644 --- a/packages/react/components/sidebar/sidebar-root.tsx +++ b/packages/react/components/sidebar/sidebar-root.tsx @@ -24,6 +24,8 @@ export interface SidebarContextValue { setOpen: (open: boolean) => void; /** Mirrors the `collapsible` prop; `'none'` means it cannot be collapsed. */ collapsible: 'icon' | 'hidden' | 'none'; + side: 'left' | 'right'; + /** @deprecated Use `side`. */ position: 'left' | 'right'; hideItemTooltips?: boolean; /** id of the sidebar element, for `aria-controls` on toggle controls. */ @@ -50,6 +52,7 @@ const FALLBACK_CONTEXT: SidebarContextValue = { open: true, setOpen: () => undefined, collapsible: 'icon', + side: 'left', position: 'left', hideItemTooltips: undefined }; @@ -67,6 +70,12 @@ export const SidebarPopupContext = createContext<(open: boolean) => void>( ); export interface SidebarRootProps extends ComponentProps<'aside'> { + /** + * The edge of the layout the sidebar sits on. + * @default 'left' + */ + side?: 'left' | 'right'; + /** @deprecated Use `side`. */ position?: 'left' | 'right'; variant?: 'plain' | 'floating' | 'inset'; /** @@ -111,7 +120,8 @@ const DEFAULT_PEEK_DELAY = 100; export function SidebarRoot({ className, id: providedId, - position = 'left', + side: providedSide, + position, variant = 'plain', open: providedOpen, onOpenChange, @@ -129,6 +139,7 @@ export function SidebarRoot({ }: SidebarRootProps) { const generatedId = useId(); const sidebarId = providedId ?? generatedId; + const side = providedSide ?? position ?? 'left'; const [open, setOpenUnwrapped] = useControlled({ controlled: providedOpen, default: defaultOpen, @@ -215,7 +226,8 @@ export function SidebarRoot({ open, setOpen: handleOpenChange, collapsible, - position, + side, + position: side, hideItemTooltips, sidebarId }} @@ -224,7 +236,7 @@ export function SidebarRoot({