diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 1b9ce183..f80fabf1 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -362,4 +362,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q2 2026', }, + { + component: 'Sidebar', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q3 2026', + }, ]; diff --git a/packages/components/src/components/Sidebar/Sidebar.mdx b/packages/components/src/components/Sidebar/Sidebar.mdx new file mode 100644 index 00000000..244df0cf --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.mdx @@ -0,0 +1,98 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './Sidebar.stories'; + + + +# Sidebar + + + +Sidebar is a low-level layout box for a side area that toggles between an open and a closed state, +animating its inline size between the two. + +It is not an overlay and does not position itself. Sidebar only controls its inline size and +open/closed state. + +## Import + +```tsx +import { Sidebar } from '@koobiq/react-components'; +``` + +## Usage + +Pass a function as `children` to render content based on the current state. + +```tsx + + {({ isOpen, toggle }) => } + +``` + + + +## Props + + + +## Open state + +The Sidebar is closed by default. Use `defaultOpen` for an uncontrolled sidebar, or pass `isOpen` and +`onOpenChange` to control it. + + + +## Sizing + +The `size` and `closedSize` props accept numbers in pixels or CSS inline-size values. + +```tsx + + + +``` + + + +## Placement + +The `placement` anchors the content to its edge while the inline size animates, so a right-hand sidebar +collapses toward the right. It does not position the box. + + + +## Keyboard + +Press `[` to toggle a `start` sidebar or `]` to toggle an `end` sidebar. Set +`keyboardShortcut` to use another key combination, or pass `null` to disable it. + +```tsx + + + +``` + + + +## Accessibility + +A Sidebar has no ARIA role by default: + +- Use `as="nav"` or `as="aside"` and add an accessible name. +- Mark the toggle control you render with `aria-expanded` and `aria-controls` pointing at the + sidebar's `id`. + +## CSS variables + +| Variable | Default | Purpose | +| ------------------------------ | ------- | ---------------------------------------------------------- | +| `--kbq-sidebar-size` | `240px` | Inline size while open (also settable via `size`). | +| `--kbq-sidebar-closed-size` | `32px` | Inline size while closed (also settable via `closedSize`). | +| `--kbq-sidebar-open-duration` | `200ms` | Duration of the expand animation. | +| `--kbq-sidebar-close-duration` | `100ms` | Duration of the collapse animation. | diff --git a/packages/components/src/components/Sidebar/Sidebar.module.css b/packages/components/src/components/Sidebar/Sidebar.module.css new file mode 100644 index 00000000..73f5f7b1 --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.module.css @@ -0,0 +1,33 @@ +.base { + --kbq-sidebar-size: 240px; + --kbq-sidebar-closed-size: 32px; + --kbq-sidebar-open-duration: 200ms; + --kbq-sidebar-close-duration: 100ms; + + display: flex; + overflow: hidden; + box-sizing: border-box; + block-size: 100%; + interpolate-size: allow-keywords; /* Kept for future intrinsic-size transition support. */ + transition: inline-size var(--kbq-transition-slow); + transition-duration: var(--kbq-sidebar-close-duration); +} + +.base[data-placement='end'] { + justify-content: flex-end; +} + +/* animation */ +.base[data-transition='entering'] { + transition-duration: var(--kbq-sidebar-open-duration); +} + +.base[data-transition='entering'], +.base[data-transition='entered'] { + inline-size: var(--kbq-sidebar-size); +} + +.base[data-transition='exiting'], +.base[data-transition='exited'] { + inline-size: var(--kbq-sidebar-closed-size); +} diff --git a/packages/components/src/components/Sidebar/Sidebar.stories.tsx b/packages/components/src/components/Sidebar/Sidebar.stories.tsx new file mode 100644 index 00000000..fe1e830b --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.stories.tsx @@ -0,0 +1,264 @@ +import type { CSSProperties } from 'react'; + +import { useBoolean } from '@koobiq/react-core'; +import { + IconCloud16, + IconDashboard16, + IconDatabase16, + IconChevronDoubleLeftS16, + IconChevronDoubleRightS16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { Grid } from '../Grid'; +import { spacing } from '../layout'; +import { List } from '../List'; +import { Typography } from '../Typography'; + +import { + Sidebar, + type SidebarProps, + type SidebarRenderProps, + sidebarPropPlacement, +} from './index'; + +const items = [ + { icon: , label: 'Dashboard' }, + { icon: , label: 'Storage' }, + { icon: , label: 'Network' }, +]; + +const meta = { + title: 'Components/Sidebar', + component: Sidebar, + argTypes: { + placement: { + options: sidebarPropPlacement, + control: { type: 'inline-radio' }, + }, + }, + tags: ['status:new', 'date:2026-07-15'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const containerStyle = { + display: 'flex', + blockSize: 320, + border: '1px solid var(--kbq-line-contrast-less)', +} as CSSProperties; + +const sidebarStyle = { + backgroundColor: 'var(--kbq-background-bg-tertiary)', + flex: 'none', +} as CSSProperties; + +const listStyle = { + flex: 'none', + inlineSize: '100%', +} as CSSProperties; + +const contentStyle = { + display: 'flex', + gap: 'var(--kbq-size-xs)', + flexDirection: 'column', + alignItems: 'start', + padding: 'var(--kbq-size-m)', + flex: '1 1 auto', + minInlineSize: 0, +} as CSSProperties; + +const Content = ({ + isOpen, + toggle, + placement = 'start', +}: Pick & { + placement?: SidebarProps['placement']; +}) => ( +
+ + {items.map(({ icon, label }) => ( + + {icon} + {isOpen && {label}} + + ))} + + + +
+); + +export const Base: Story = { + render: (args) => ( +
+ + {({ isOpen, toggle }) => } + +
+ + Press [ to toggle the sidebar. + +
+
+ ), +}; + +export const Controlled: Story = { + render: function Render(args) { + const [isOpen, { toggle, set }] = useBoolean(true); + + return ( +
+ + {({ isOpen, toggle }) => } + +
+ The sidebar is {isOpen ? 'open' : 'closed'}. + +
+
+ ); + }, +}; + +export const Size: Story = { + render: function Render() { + const sizeExamples: Array<{ + label: string; + size: SidebarProps['size']; + closedSize: SidebarProps['closedSize']; + }> = [ + { label: 'Pixels: 320px → 56px', size: 320, closedSize: 56 }, + { label: 'Percentages: 50% → 25%', size: '50%', closedSize: '25%' }, + { + label: 'Clamp: min 240px, preferred 50%, max 320px', + size: 'clamp(240px, 50%, 320px)', + closedSize: 32, + }, + ]; + + return ( + + {sizeExamples.map(({ label, size, closedSize }) => ( + + {label} + + + + Sidebar + + + + + Content + + + + + ))} + + ); + }, +}; + +export const Placement: Story = { + render: () => ( +
+ + {({ isOpen, toggle }) => ( + + )} + +
+ + [ toggles the left sidebar, ] the right one. + +
+ + {({ isOpen, toggle }) => ( + + )} + +
+ ), +}; + +export const Keyboard: Story = { + render: (args) => ( +
+ + {({ isOpen, toggle }) => } + +
+ + Press B to toggle the sidebar. + +
+
+ ), +}; diff --git a/packages/components/src/components/Sidebar/Sidebar.test.tsx b/packages/components/src/components/Sidebar/Sidebar.test.tsx new file mode 100644 index 00000000..4867b531 --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.test.tsx @@ -0,0 +1,478 @@ +import { createRef } from 'react'; + +import { screen, render, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; + +import { Sidebar, sidebarPropPlacement } from './index.js'; +import type { SidebarRenderProps } from './index.js'; + +describe('Sidebar', () => { + const baseProps = { 'data-testid': 'sidebar' }; + + const getRoot = () => screen.getByTestId('sidebar'); + + const content = ({ isOpen, open, close, toggle }: SidebarRenderProps) => + isOpen ? ( +
+ + +
+ ) : ( +
+ + +
+ ); + + it('should receive ref', () => { + const ref = createRef(); + const { container } = render(); + + expect(ref.current).toBe(container.querySelector('div')); + }); + + it('should render the component with the correct tag', () => { + render(); + + expect(getRoot().tagName).toBe('NAV'); + }); + + it('should merge the consumer style with the component variables', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe( + '240px' + ); + + expect(getRoot().style.color).toBe('red'); + }); + + it('should add no role or aria attributes by default', () => { + render(); + + expect(getRoot()).not.toHaveAttribute('role'); + expect(getRoot()).not.toHaveAttribute('aria-label'); + }); + + it('should forward role and aria-label', () => { + render(); + + expect(getRoot()).toHaveAttribute('role', 'navigation'); + expect(getRoot()).toHaveAttribute('aria-label', 'Main'); + }); + + describe('check the content', () => { + it('should render static children', () => { + render( + + + + ); + + expect(screen.getByTestId('static')).toBeInTheDocument(); + }); + + it('should render a ReactNode returned by the render function', () => { + render({() => 'render content'}); + + expect(getRoot()).toHaveTextContent('render content'); + }); + + it('should pass the state and the actions to the render function', () => { + const children = vi.fn().mockReturnValue(null); + + render({children}); + + expect(children).toHaveBeenCalledWith({ + isOpen: false, + open: expect.any(Function), + close: expect.any(Function), + toggle: expect.any(Function), + }); + }); + + it('should keep the children mounted when closed', () => { + render( + + + + ); + + expect(getRoot()).toHaveAttribute('data-transition', 'exited'); + expect(screen.getByTestId('static')).toBeInTheDocument(); + }); + }); + + describe('check the open state', () => { + it('should be closed by default', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-transition', 'exited'); + expect(getRoot()).not.toHaveAttribute('data-open'); + }); + + it('should be open when defaultOpen is set', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-transition', 'entered'); + expect(getRoot()).toHaveAttribute('data-open', 'true'); + }); + + it('should follow the isOpen prop when controlled', () => { + const { rerender } = render(); + + expect(getRoot()).toHaveAttribute('data-transition', 'exited'); + + rerender(); + + expect(getRoot()).toHaveAttribute('data-transition', 'entering'); + }); + + it('should not call onOpenChange on mount', () => { + const onOpenChange = vi.fn(); + + render( + + ); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it.each([ + ['open', false, 'open', true], + ['close', true, 'close', false], + ['toggle from closed', false, 'toggle', true], + ['toggle from open', true, 'toggle', false], + ])('should %s', async (_, defaultOpen, button, expected) => { + const onOpenChange = vi.fn(); + + render( + + {content} + + ); + + await userEvent.click(screen.getByRole('button', { name: button })); + + expect(onOpenChange).toHaveBeenCalledWith(expected); + }); + + it('should not change the rendered state when the controlled parent ignores it', async () => { + render( + + {content} + + ); + + await userEvent.click(screen.getByRole('button', { name: 'close' })); + + expect(getRoot()).toHaveAttribute('data-transition', 'entered'); + expect(screen.getByTestId('open-content')).toBeInTheDocument(); + }); + }); + + describe('check the content swap', () => { + it('should keep the open content for the whole collapse', async () => { + render( + + {content} + + ); + + await userEvent.click(screen.getByRole('button', { name: 'close' })); + + // The box is already shrinking, but the open content is still there. + expect(getRoot()).toHaveAttribute('data-transition', 'exiting'); + expect(screen.getByTestId('open-content')).toBeInTheDocument(); + expect(screen.queryByTestId('closed-content')).not.toBeInTheDocument(); + + await waitFor(() => + expect(screen.getByTestId('closed-content')).toBeInTheDocument() + ); + + expect(getRoot()).toHaveAttribute('data-transition', 'exited'); + }); + + it('should show the open content as soon as the expand starts', async () => { + render({content}); + + await userEvent.click(screen.getByRole('button', { name: 'open' })); + + // Asymmetric with the collapse on purpose: no waiting here. + expect(getRoot()).toHaveAttribute('data-transition', 'entering'); + expect(screen.getByTestId('open-content')).toBeInTheDocument(); + + await waitFor(() => + expect(getRoot()).toHaveAttribute('data-transition', 'entered') + ); + }); + }); + + describe('check the size props', () => { + it('should set no size variables by default', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe(''); + + expect( + getRoot().style.getPropertyValue('--kbq-sidebar-closed-size') + ).toBe(''); + }); + + it('should set numeric size variables in pixels', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe( + '320px' + ); + + expect( + getRoot().style.getPropertyValue('--kbq-sidebar-closed-size') + ).toBe('56px'); + }); + + it.each([ + ['auto', '32px'], + ['50%', '25%'], + ])('should pass CSS size values through: %s and %s', (size, closedSize) => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe(size); + + expect( + getRoot().style.getPropertyValue('--kbq-sidebar-closed-size') + ).toBe(closedSize); + }); + }); + + describe('check the placement prop', () => { + it('should default to the start placement', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-placement', 'start'); + }); + + it.each(sidebarPropPlacement)( + 'should apply the placement as a "%s"', + (placement) => { + render(); + + expect(getRoot()).toHaveAttribute('data-placement', placement); + } + ); + }); + + describe('check the keyboard shortcut', () => { + it.each([ + ['start', 'BracketLeft'], + ['end', 'BracketRight'], + ] as const)('should toggle a "%s" sidebar with %s', (placement, code) => { + const onOpenChange = vi.fn(); + + render( + + ); + + fireEvent.keyDown(window, { code }); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it.each([ + ['start', 'BracketRight'], + ['end', 'BracketLeft'], + ] as const)( + 'should not toggle a "%s" sidebar with %s', + (placement, code) => { + const onOpenChange = vi.fn(); + + render( + + ); + + fireEvent.keyDown(window, { code }); + + expect(onOpenChange).not.toHaveBeenCalled(); + } + ); + + // The reason we match `code` and not `key`: on the ЙЦУКЕН layout the bracket + // keys produce "х" and "ъ", so a `key` match would never fire. + it('should match the physical key regardless of the layout', () => { + const onOpenChange = vi.fn(); + + render(); + + fireEvent.keyDown(window, { code: 'BracketLeft', key: 'х' }); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('should use a custom key', () => { + const onOpenChange = vi.fn(); + + render( + + ); + + fireEvent.keyDown(window, { code: 'BracketLeft' }); + expect(onOpenChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { code: 'KeyB' }); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('should ignore repeated keydown events', () => { + const onOpenChange = vi.fn(); + + render(); + + fireEvent.keyDown(window, { code: 'BracketLeft' }); + fireEvent.keyDown(window, { code: 'BracketLeft', repeat: true }); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('should match custom modifiers exactly and prevent the default action', () => { + const onOpenChange = vi.fn(); + + render( + + ); + + fireEvent.keyDown(window, { code: 'KeyO' }); + expect(onOpenChange).not.toHaveBeenCalled(); + + const event = new KeyboardEvent('keydown', { + code: 'KeyO', + metaKey: true, + cancelable: true, + }); + + window.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it.each(['ctrlKey', 'metaKey', 'altKey', 'shiftKey'] as const)( + 'should ignore the shortcut when %s is held', + (modifier) => { + const onOpenChange = vi.fn(); + + render(); + + fireEvent.keyDown(window, { code: 'BracketLeft', [modifier]: true }); + + expect(onOpenChange).not.toHaveBeenCalled(); + } + ); + + it('should ignore the shortcut while typing', () => { + const onOpenChange = vi.fn(); + + render( + <> + + + + ); + + fireEvent.keyDown(screen.getByTestId('input'), { code: 'BracketLeft' }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('should ignore the shortcut inside a contenteditable', () => { + const onOpenChange = vi.fn(); + + render( + <> +
+ + + ); + + const editor = screen.getByTestId('editor'); + // jsdom does not implement isContentEditable at all, so define it here. + Object.defineProperty(editor, 'isContentEditable', { value: true }); + + fireEvent.keyDown(editor, { code: 'BracketLeft' }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('should not listen when keyboardShortcut is null', () => { + const onOpenChange = vi.fn(); + + render( + + ); + + fireEvent.keyDown(window, { code: 'BracketLeft' }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('should stop listening on unmount', () => { + const onOpenChange = vi.fn(); + + const { unmount } = render( + + ); + + unmount(); + fireEvent.keyDown(window, { code: 'BracketLeft' }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); + + it('should merge the transition slot props', async () => { + const onExited = vi.fn(); + + render( + + {content} + + ); + + await userEvent.click(screen.getByRole('button', { name: 'close' })); + await waitFor(() => expect(onExited).toHaveBeenCalled()); + + expect(screen.getByTestId('closed-content')).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/components/Sidebar/Sidebar.tsx b/packages/components/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 00000000..e7acf828 --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useCallback } from 'react'; +import type { ComponentPropsWithRef, CSSProperties, ElementType } from 'react'; + +import { + clsx, + polymorphicForwardRef, + useControlledState, + useDOMRef, + useEventListener, +} from '@koobiq/react-core'; +import { Transition } from 'react-transition-group'; + +import { TRANSITION_TIMEOUT } from './constants'; +import type { SidebarBaseProps } from './index'; +import s from './Sidebar.module.css'; +import { + isEditableTarget, + matchesKeyboardShortcut, + normalizeSize, +} from './utils'; + +/** + * Sidebar is a low-level layout box for a side area that toggles between an open + * and a closed state, animating its inline size between the two. + */ +export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>( + (props, ref) => { + const { + as: Tag = 'div', + isOpen: isOpenProp, + defaultOpen, + onOpenChange, + size, + closedSize, + placement = 'start', + keyboardShortcut, + slotProps, + style, + className, + children, + ...other + } = props; + + const domRef = useDOMRef(ref); + + const [isOpen, setOpen] = useControlledState( + isOpenProp, + defaultOpen ?? false, + onOpenChange + ); + + const open = useCallback(() => setOpen(true), [setOpen]); + const close = useCallback(() => setOpen(false), [setOpen]); + const toggle = useCallback(() => setOpen((is) => !is), [setOpen]); + + useEventListener({ + eventName: 'keydown', + active: keyboardShortcut !== null, + handler: (event) => { + if (keyboardShortcut === null || event.repeat) return; + + const shortcut = keyboardShortcut ?? { + code: placement === 'end' ? 'BracketRight' : 'BracketLeft', + }; + + if (!matchesKeyboardShortcut(event, shortcut)) return; + if (isEditableTarget(event.target)) return; + + event.preventDefault(); + toggle(); + }, + }); + + const sidebarStyle = { + ...style, + ...(size !== undefined && { '--kbq-sidebar-size': normalizeSize(size) }), + ...(closedSize !== undefined && { + '--kbq-sidebar-closed-size': normalizeSize(closedSize), + }), + '--kbq-sidebar-open-duration': `${TRANSITION_TIMEOUT.enter}ms`, + '--kbq-sidebar-close-duration': `${TRANSITION_TIMEOUT.exit}ms`, + } as CSSProperties; + + const transitionProps = { + timeout: TRANSITION_TIMEOUT, + in: isOpen, + nodeRef: domRef, + ...slotProps?.transition, + }; + + return ( + + {(transition) => { + // The open content stays mounted for the whole collapse and is swapped + // for the closed content only once the sidebar has finished collapsing. + const isOpenState = transition !== 'exited'; + + return ( + + {typeof children === 'function' + ? children({ isOpen: isOpenState, open, close, toggle }) + : children} + + ); + }} + + ); + } +); + +export type SidebarProps = + ComponentPropsWithRef>; diff --git a/packages/components/src/components/Sidebar/constants.ts b/packages/components/src/components/Sidebar/constants.ts new file mode 100644 index 00000000..953c8cde --- /dev/null +++ b/packages/components/src/components/Sidebar/constants.ts @@ -0,0 +1,6 @@ +/** + * Transition durations, in ms. Asymmetric on purpose: collapsing is snappier + * than expanding. The CSS reads these through custom properties, so this stays + * the single source of truth. + */ +export const TRANSITION_TIMEOUT = { enter: 200, exit: 100 } as const; diff --git a/packages/components/src/components/Sidebar/index.ts b/packages/components/src/components/Sidebar/index.ts new file mode 100644 index 00000000..dfc1c22d --- /dev/null +++ b/packages/components/src/components/Sidebar/index.ts @@ -0,0 +1,2 @@ +export * from './Sidebar'; +export * from './types'; diff --git a/packages/components/src/components/Sidebar/types.ts b/packages/components/src/components/Sidebar/types.ts new file mode 100644 index 00000000..5e69035f --- /dev/null +++ b/packages/components/src/components/Sidebar/types.ts @@ -0,0 +1,84 @@ +import type { CSSProperties, ReactNode } from 'react'; + +import type { TransitionProps } from 'react-transition-group/Transition'; + +export const sidebarPropPlacement = ['start', 'end'] as const; + +export type SidebarPropPlacement = (typeof sidebarPropPlacement)[number]; + +export type SidebarPropSize = CSSProperties['inlineSize']; + +export type SidebarPropKeyboardShortcut = { + /** The physical key code, for example `KeyB` or `KeyO`. */ + code: string; + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; +}; + +export type SidebarRenderProps = { + /** Whether the sidebar is currently showing its open content. */ + isOpen: boolean; + /** Opens the sidebar. */ + open: () => void; + /** Closes the sidebar. */ + close: () => void; + /** Toggles the sidebar between its open and closed states. */ + toggle: () => void; +}; + +export type SidebarPropContent = + | ReactNode + | ((props: SidebarRenderProps) => ReactNode); + +export type SidebarBaseProps = { + /** + * The content of the sidebar. Pass a function to render different content per + * state — it receives the current state and the `open`, `close` and `toggle` + * actions. + */ + children?: SidebarPropContent; + /** If `true`, the sidebar is open. */ + isOpen?: boolean; + /** + * The default open state. Use when the component is not controlled. + * @default false + */ + defaultOpen?: boolean; + /** Handler that is called when the sidebar's open state changes. */ + onOpenChange?: (isOpen: boolean) => void; + /** + * The inline size of the sidebar while it is open. Numbers are treated as + * pixels; strings are passed through as CSS values. + * @default 240 + */ + size?: SidebarPropSize; + /** + * The inline size of the sidebar while it is closed. Numbers are treated as + * pixels; strings are passed through as CSS values. + * @default 32 + */ + closedSize?: SidebarPropSize; + /** + * The side the sidebar is placed on. Anchors the content to that edge while + * the inline size animates, and picks the default keyboard shortcut: `start` + * is toggled by `[`, `end` by `]`. + * @default 'start' + */ + placement?: SidebarPropPlacement; + /** + * The keyboard shortcut that toggles the sidebar. By default, it is selected + * from `placement`. Set to `null` to disable the keyboard shortcut. + * @example { code: 'KeyO', metaKey: true } + */ + keyboardShortcut?: SidebarPropKeyboardShortcut | null; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** The props used for each slot inside. */ + slotProps?: { + transition?: Partial>; + }; +}; diff --git a/packages/components/src/components/Sidebar/utils.ts b/packages/components/src/components/Sidebar/utils.ts new file mode 100644 index 00000000..2c6023c8 --- /dev/null +++ b/packages/components/src/components/Sidebar/utils.ts @@ -0,0 +1,28 @@ +import { isNumber } from '@koobiq/react-core'; + +import type { SidebarPropKeyboardShortcut, SidebarPropSize } from './types'; + +export const normalizeSize = (value: SidebarPropSize) => + isNumber(value) ? `${value}px` : value; + +export const matchesKeyboardShortcut = ( + event: KeyboardEvent, + shortcut: SidebarPropKeyboardShortcut +) => + event.code === shortcut.code && + event.altKey === Boolean(shortcut.altKey) && + event.ctrlKey === Boolean(shortcut.ctrlKey) && + event.metaKey === Boolean(shortcut.metaKey) && + event.shiftKey === Boolean(shortcut.shiftKey); + +/** + * Whether the event target is somewhere the user types, so a printable-character + * shortcut must not steal the key. + */ +export const isEditableTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + + if (target.isContentEditable) return true; + + return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName); +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index b02814d8..9837688b 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -55,6 +55,7 @@ export * from './Tree'; export * from './TreeSelect'; export * from './EmptyState'; export * from './Flag'; +export * from './Sidebar'; export * from './layout'; export { useListData, diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 4faa8ef5..205f5c09 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -40,6 +40,7 @@ "RadioGroup", "SearchInput", "Select", + "Sidebar", "SidePanel", "SkeletonBlock", "SkeletonTypography", diff --git a/tools/public_api_guard/components/Sidebar.api.md b/tools/public_api_guard/components/Sidebar.api.md new file mode 100644 index 00000000..e048c2d2 --- /dev/null +++ b/tools/public_api_guard/components/Sidebar.api.md @@ -0,0 +1,68 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ComponentPropsWithRef } from 'react'; +import type { CSSProperties } from 'react'; +import type { ElementType } from 'react'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import type { ReactNode } from 'react'; +import type { TransitionProps } from 'react-transition-group/Transition'; + +// @public +export const Sidebar: PolyForwardComponent<"div", SidebarBaseProps, ElementType>; + +// @public (undocumented) +export type SidebarBaseProps = { + children?: SidebarPropContent; + isOpen?: boolean; + defaultOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + size?: SidebarPropSize; + closedSize?: SidebarPropSize; + placement?: SidebarPropPlacement; + keyboardShortcut?: SidebarPropKeyboardShortcut | null; + className?: string; + style?: CSSProperties; + slotProps?: { + transition?: Partial>; + }; +}; + +// @public (undocumented) +export type SidebarPropContent = ReactNode | ((props: SidebarRenderProps) => ReactNode); + +// @public (undocumented) +export type SidebarPropKeyboardShortcut = { + code: string; + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; +}; + +// @public (undocumented) +export type SidebarPropPlacement = (typeof sidebarPropPlacement)[number]; + +// @public (undocumented) +export const sidebarPropPlacement: readonly ["start", "end"]; + +// @public (undocumented) +export type SidebarProps = ComponentPropsWithRef>; + +// @public (undocumented) +export type SidebarPropSize = CSSProperties['inlineSize']; + +// @public (undocumented) +export type SidebarRenderProps = { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +}; + +// (No @packageDocumentation comment for this package) + +```