From a18aa3706e91d57264f53466dc40cec450b84ed9 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 15 Jul 2026 11:03:16 +0300 Subject: [PATCH 1/5] chore(Sidebar): add initial component draft --- .storybook/components/Roadmap/data.ts | 6 + .../src/components/Sidebar/Sidebar.mdx | 158 +++++++ .../src/components/Sidebar/Sidebar.module.css | 36 ++ .../Sidebar/Sidebar.stories.module.css | 48 +++ .../components/Sidebar/Sidebar.stories.tsx | 244 +++++++++++ .../src/components/Sidebar/Sidebar.test.tsx | 399 ++++++++++++++++++ .../src/components/Sidebar/Sidebar.tsx | 124 ++++++ .../src/components/Sidebar/constants.ts | 6 + .../src/components/Sidebar/index.ts | 2 + .../src/components/Sidebar/types.ts | 70 +++ .../src/components/Sidebar/utils.ts | 13 + packages/components/src/components/index.ts | 1 + tools/api-extractor/config.json | 1 + vite.config.mts | 6 +- 14 files changed, 1113 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/components/Sidebar/Sidebar.mdx create mode 100644 packages/components/src/components/Sidebar/Sidebar.module.css create mode 100644 packages/components/src/components/Sidebar/Sidebar.stories.module.css create mode 100644 packages/components/src/components/Sidebar/Sidebar.stories.tsx create mode 100644 packages/components/src/components/Sidebar/Sidebar.test.tsx create mode 100644 packages/components/src/components/Sidebar/Sidebar.tsx create mode 100644 packages/components/src/components/Sidebar/constants.ts create mode 100644 packages/components/src/components/Sidebar/index.ts create mode 100644 packages/components/src/components/Sidebar/types.ts create mode 100644 packages/components/src/components/Sidebar/utils.ts diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 1b9ce1830..f80fabf1d 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 000000000..03e82e9fc --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.mdx @@ -0,0 +1,158 @@ +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: there is no backdrop, no focus trap, and the content stays mounted while +closed. Where the box sits is up to your layout — Sidebar only owns its own inline size. It holds no +state beyond open/closed and persists nothing. + +## Import + +```tsx +import { Sidebar } from '@koobiq/react-components'; +``` + +## Usage + +Pass a function as `children` to render a different tree per state. It receives the current state +plus the `open`, `close` and `toggle` actions, so the control that collapses the sidebar lives in +your content. + +```tsx + + {({ isOpen, open, close }) => + isOpen ? ( + + ) : ( + + ) + } + +``` + + + +## Props + + + +## Open state + +Sidebar works controlled and uncontrolled. Without `isOpen` it owns its state, and `defaultOpen` sets +the initial one — it is **closed by default**. + + + +Pass `isOpen` together with `onOpenChange` to own the state yourself. + + + +## Content and the state swap + +The render function is optional — plain children work too and are simply clipped by the box as it +collapses. + + + +One detail worth knowing: **the open content is kept for the whole collapse** and is swapped for the +closed content only once the animation has finished. Otherwise the compact content would appear +instantly inside a box that is still at its full width, and you would see it jump. On expand the +swap is immediate — the open content is there from the first frame, while the box is still growing. + +This means the render function's `isOpen` stays `true` for the duration of the collapse, even though +the `isOpen` prop is already `false`. It reports what is on screen, not what has been requested. + +## Sizing + +`size` and `closedSize` set the inline size of each state, in pixels. + +```tsx + +``` + + + +## Placement + +`placement` anchors the content to its edge while the inline size animates, so a right-hand sidebar +collapses toward the right. It also picks the keyboard shortcut (see below). It does **not** position +the box — that is your layout's job. + + + +By default the content shrinks along with the box, which is usually what you want (labels ellipsize, +icons stay put). To have the content hold its width and be clipped from the edge instead — which is +what makes the anchoring visible — give it `flex-shrink: 0` or a fixed inline size. + +## Keyboard + +| Key | Action | +| --- | ------------------------------------- | +| `[` | Toggles a `placement="start"` sidebar | +| `]` | Toggles a `placement="end"` sidebar | + +The shortcut is bound on `window`, so it works regardless of focus, and it is ignored while the user +is typing or holding a modifier. It matches the physical key, so it also works on non-Latin layouts. + +Set `disableKeyboardShortcut` to turn it off. Note the shortcut assumes at most one sidebar per +`placement` on a page — two would both toggle on the same key. + +## Transition + +Expanding takes 200ms and collapsing 100ms. Reach the underlying transition through +`slotProps.transition`; its `onEntered` and `onExited` fire once the animation has finished. + + + +## Accessibility + +A sidebar is a box that changes size and carries no semantics of its own, so Sidebar adds no `role` +and no ARIA attributes. Supply them yourself: + +- Use `as="nav"` / `as="aside"` (or pass `role`) and give the region an accessible name with + `aria-label`, so it becomes a landmark. +- Mark the toggle control you render with `aria-expanded` and `aria-controls` pointing at the + sidebar's `id`. + +## Persisting the state + +Sidebar persists nothing. Keep the state where the rest of your app state lives and drive the +component with `isOpen`: + +```tsx +const [isOpen, setIsOpen] = useState( + () => localStorage.getItem('sidebar') === 'open' +); + +const handleOpenChange = (open: boolean) => { + setIsOpen(open); + localStorage.setItem('sidebar', open ? 'open' : 'closed'); +}; + + + {/* … */} +; +``` + +## CSS variables + +| Variable | Default | Purpose | +| -------------------------- | --------------------- | ---------------------------------------------------------- | +| `--sidebar-size` | `240px` | Inline size while open (also settable via `size`). | +| `--sidebar-closed-size` | `var(--kbq-size-3xl)` | Inline size while closed (also settable via `closedSize`). | +| `--sidebar-open-duration` | `200ms` | Duration of the expand animation. | +| `--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 000000000..0fa4d7d1a --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.module.css @@ -0,0 +1,36 @@ +.base { + --sidebar-size: 240px; + --sidebar-closed-size: var(--kbq-size-3xl); + --sidebar-open-duration: 200ms; + --sidebar-close-duration: 100ms; + + display: flex; + overflow: hidden; + box-sizing: border-box; + block-size: 100%; + + /* The token supplies the easing; the duration is overridden per phase below. */ + transition: inline-size var(--kbq-transition-slow); + transition-duration: var(--sidebar-close-duration); +} + +/* Anchors the content to its edge while the inline size animates. + `start` is the flexbox default and needs no rule. */ +.base[data-placement='end'] { + justify-content: flex-end; +} + +/* animation */ +.base[data-transition='entering'] { + transition-duration: var(--sidebar-open-duration); +} + +.base[data-transition='entering'], +.base[data-transition='entered'] { + inline-size: var(--sidebar-size); +} + +.base[data-transition='exiting'], +.base[data-transition='exited'] { + inline-size: var(--sidebar-closed-size); +} diff --git a/packages/components/src/components/Sidebar/Sidebar.stories.module.css b/packages/components/src/components/Sidebar/Sidebar.stories.module.css new file mode 100644 index 000000000..b7ad97d46 --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.stories.module.css @@ -0,0 +1,48 @@ +.layout { + display: flex; + block-size: 320px; + inline-size: 640px; + border: 1px solid var(--kbq-line-contrast-less); + border-radius: var(--kbq-size-s); + overflow: hidden; +} + +.sidebar { + background-color: var(--kbq-background-bg-tertiary); + flex: none; +} + +.main { + flex: 1 1 auto; + padding: var(--kbq-size-l); +} + +/* `flex: none` keeps the panel at its open width while the sidebar shrinks, so + the content is clipped from the edge instead of reflowing. */ +.panel { + display: flex; + flex: none; + flex-direction: column; + gap: var(--kbq-size-xs); + padding: var(--kbq-size-s); + inline-size: 240px; +} + +.compact { + display: flex; + flex: none; + flex-direction: column; + gap: var(--kbq-size-xs); + padding: var(--kbq-size-xs); + align-items: center; +} + +.item { + display: flex; + align-items: center; + gap: var(--kbq-size-s); + padding: var(--kbq-size-xs) var(--kbq-size-s); + border-radius: var(--kbq-size-xxs); + color: var(--kbq-foreground-contrast); + white-space: nowrap; +} 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 000000000..4eed949c1 --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.stories.tsx @@ -0,0 +1,244 @@ +import { useState } from 'react'; + +import { + IconChevronDoubleLeftS16, + IconChevronDoubleRightS16, + IconCloud16, + IconDashboard16, + IconDatabase16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { IconButton } from '../IconButton'; +import { Typography } from '../Typography'; + +import { Sidebar, type SidebarProps, sidebarPropPlacement } from './index.js'; +import s from './Sidebar.stories.module.css'; + +const items = [ + { icon: , label: 'Dashboard' }, + { icon: , label: 'Storage' }, + { icon: , label: 'Network' }, +]; + +const meta = { + title: 'Components/Sidebar', + component: Sidebar, + parameters: { + layout: 'centered', + }, + argTypes: { + placement: { + options: sidebarPropPlacement, + control: { type: 'inline-radio' }, + }, + }, + tags: ['status:new', 'date:2026-07-15'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const Panel = ({ onCollapse }: { onCollapse: () => void }) => ( +
+ + + + {items.map(({ icon, label }) => ( +
+ {icon} + {label} +
+ ))} +
+); + +const Compact = ({ onExpand }: { onExpand: () => void }) => ( +
+ + + + {items.map(({ icon, label }) => ( +
+ {icon} +
+ ))} +
+); + +export const Base: Story = { + render: (args) => ( +
+ + {({ isOpen, open, close }) => + isOpen ? : + } + +
+ + Press [ to toggle the sidebar. + +
+
+ ), +}; + +/** + * Without `isOpen` the sidebar owns its state. `defaultOpen` sets the initial one — + * it is closed by default. + */ +export const Uncontrolled: Story = { + render: (args) => ( +
+ + {({ isOpen, open, close }) => + isOpen ? : + } + +
+
+ ), +}; + +/** + * Pass `isOpen` and `onOpenChange` to own the state. Note `onOpenChange` fires + * immediately, while the render function keeps reporting the open state until the + * collapse animation has finished. + */ +export const Controlled: Story = { + render: function Render(args) { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + {({ isOpen: isOpenState, open, close }) => + isOpenState ? ( + + ) : ( + + ) + } + +
+ + The sidebar is {isOpen ? 'open' : 'closed'}. + +
+
+ ); + }, +}; + +/** + * The render function is optional. Plain children stay mounted in both states and + * are simply clipped by the box as it collapses. + */ +export const StaticContent: Story = { + render: (args) => ( +
+ +
+ {items.map(({ icon, label }) => ( +
+ {icon} + {label} +
+ ))} +
+
+
+ Press [ to toggle. +
+
+ ), +}; + +/** `size` and `closedSize` set the inline size of each state, in pixels. */ +export const Sizing: Story = { + render: (args) => ( +
+ + {({ isOpen, open, close }) => + isOpen ? : + } + +
+
+ ), +}; + +/** + * `placement` anchors the content to its edge while the inline size animates, and + * picks the shortcut: `[` toggles the `start` sidebar, `]` the `end` one. + */ +export const Placement: Story = { + render: (args) => ( +
+ + {({ isOpen, open, close }) => + isOpen ? : + } + +
+ + [ toggles the left sidebar, ] the right one. + +
+ + {({ isOpen, open, close }) => + isOpen ? : + } + +
+ ), +}; + +/** + * `slotProps.transition` reaches the underlying transition. Its `onEntered` and + * `onExited` fire once the animation has finished — that is the equivalent of the + * Angular `stateChanged` output. + */ +export const CustomTransition: Story = { + render: function Render(args) { + const [log, setLog] = useState('—'); + + return ( +
+ setLog('entered'), + onExited: () => setLog('exited'), + }, + }} + > + {({ isOpen, open, close }) => + isOpen ? : + } + +
+ Last transition: {log} +
+
+ ); + }, +}; 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 000000000..7ffeefecb --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.test.tsx @@ -0,0 +1,399 @@ +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('--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 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('--sidebar-size')).toBe(''); + + expect(getRoot().style.getPropertyValue('--sidebar-closed-size')).toBe( + '' + ); + }); + + it('should set the size variables in pixels', () => { + render(); + + expect(getRoot().style.getPropertyValue('--sidebar-size')).toBe('320px'); + + expect(getRoot().style.getPropertyValue('--sidebar-closed-size')).toBe( + '56px' + ); + }); + }); + + 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.each(['ctrlKey', 'metaKey', 'altKey'] 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 disableKeyboardShortcut is set', () => { + 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 000000000..cf1b2fa3f --- /dev/null +++ b/packages/components/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,124 @@ +'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, 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. + * + * It renders arbitrary content — pass a function as `children` to render a + * different tree per state. It is not an overlay: no backdrop, no focus trap, and + * the content stays mounted while closed. + * + * Sidebar adds no ARIA semantics on its own. Supply them via standard attributes + * (`as="nav"`, `role`, `aria-label`), and mark the toggle control you render with + * `aria-expanded` and `aria-controls`. + */ +export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>( + (props, ref) => { + const { + as: Tag = 'div', + isOpen: isOpenProp, + defaultOpen, + onOpenChange, + size, + closedSize, + placement = 'start', + disableKeyboardShortcut = false, + 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: !disableKeyboardShortcut, + handler: (event) => { + // Matched by `code`, not `key`: on non-Latin layouts (e.g. ЙЦУКЕН) the + // bracket keys produce other characters and `key` would never match. + const code = placement === 'end' ? 'BracketRight' : 'BracketLeft'; + + if (event.code !== code) return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (isEditableTarget(event.target)) return; + + toggle(); + }, + }); + + const sidebarStyle = { + ...style, + ...(size !== undefined && { '--sidebar-size': normalizeSize(size) }), + ...(closedSize !== undefined && { + '--sidebar-closed-size': normalizeSize(closedSize), + }), + '--sidebar-open-duration': `${TRANSITION_TIMEOUT.enter}ms`, + '--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 000000000..953c8cde3 --- /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 000000000..dfc1c22d3 --- /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 000000000..c0ede8064 --- /dev/null +++ b/packages/components/src/components/Sidebar/types.ts @@ -0,0 +1,70 @@ +import type { CSSProperties, ReactElement, 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 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) => ReactElement); + +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, in pixels. + * @default 240 + */ + size?: number; + /** + * The inline size of the sidebar while it is closed, in pixels. + * @default 32 + */ + closedSize?: number; + /** + * The side the sidebar is placed on. Anchors the content to that edge while + * the inline size animates, and picks the keyboard shortcut: `start` is + * toggled by `[`, `end` by `]`. + * @default 'start' + */ + placement?: SidebarPropPlacement; + /** + * If `true`, the `[` / `]` keyboard shortcut is disabled. + * @default false + */ + disableKeyboardShortcut?: boolean; + /** 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 000000000..fecd9c846 --- /dev/null +++ b/packages/components/src/components/Sidebar/utils.ts @@ -0,0 +1,13 @@ +export const normalizeSize = (value: number) => `${value}px`; + +/** + * 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 b02814d83..9837688b8 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 4faa8ef58..205f5c093 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/vite.config.mts b/vite.config.mts index aed2ea060..1e8beb0a2 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -26,7 +26,11 @@ export const css: UserConfig['css'] = { const prefix = 'kbq'; const parts = [prefix]; - const fileName = path.basename(filename, '.module.css'); + // A dot would make the generated name parse as a compound selector + // (`.a.b`) and never match the element, e.g. `Flag.stories.module.css`. + const fileName = path + .basename(filename, '.module.css') + .replace(/\./g, '-'); parts.push(fileName.toLowerCase()); From ec6ec12dbf8302dc7b4e80cd589727fb6035fb4a Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 15 Jul 2026 18:18:37 +0300 Subject: [PATCH 2/5] chore(Sidebar): add new component --- .../src/components/Sidebar/Sidebar.mdx | 118 ++---- .../src/components/Sidebar/Sidebar.module.css | 7 +- .../Sidebar/Sidebar.stories.module.css | 48 --- .../components/Sidebar/Sidebar.stories.tsx | 350 +++++++++--------- .../src/components/Sidebar/Sidebar.test.tsx | 65 +++- .../src/components/Sidebar/Sidebar.tsx | 30 +- .../src/components/Sidebar/types.ts | 32 +- .../src/components/Sidebar/utils.ts | 17 +- .../components/Sidebar.api.md | 69 ++++ vite.config.mts | 6 +- 10 files changed, 400 insertions(+), 342 deletions(-) delete mode 100644 packages/components/src/components/Sidebar/Sidebar.stories.module.css create mode 100644 tools/public_api_guard/components/Sidebar.api.md diff --git a/packages/components/src/components/Sidebar/Sidebar.mdx b/packages/components/src/components/Sidebar/Sidebar.mdx index 03e82e9fc..23ed4ee58 100644 --- a/packages/components/src/components/Sidebar/Sidebar.mdx +++ b/packages/components/src/components/Sidebar/Sidebar.mdx @@ -16,9 +16,8 @@ import * as Stories from './Sidebar.stories'; 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: there is no backdrop, no focus trap, and the content stays mounted while -closed. Where the box sits is up to your layout — Sidebar only owns its own inline size. It holds no -state beyond open/closed and persists nothing. +It is not an overlay and does not position itself. Sidebar only controls its inline size and +open/closed state. ## Import @@ -28,19 +27,11 @@ import { Sidebar } from '@koobiq/react-components'; ## Usage -Pass a function as `children` to render a different tree per state. It receives the current state -plus the `open`, `close` and `toggle` actions, so the control that collapses the sidebar lives in -your content. +Pass a function as `children` to render content based on the current state. ```tsx - - {({ isOpen, open, close }) => - isOpen ? ( - - ) : ( - - ) - } + + {({ isOpen, toggle }) => } ``` @@ -52,107 +43,56 @@ your content. ## Open state -Sidebar works controlled and uncontrolled. Without `isOpen` it owns its state, and `defaultOpen` sets -the initial one — it is **closed by default**. - - - -Pass `isOpen` together with `onOpenChange` to own the state yourself. +The Sidebar is closed by default. Use `defaultOpen` for an uncontrolled sidebar, or pass `isOpen` and +`onOpenChange` to control it. -## Content and the state swap - -The render function is optional — plain children work too and are simply clipped by the box as it -collapses. - - - -One detail worth knowing: **the open content is kept for the whole collapse** and is swapped for the -closed content only once the animation has finished. Otherwise the compact content would appear -instantly inside a box that is still at its full width, and you would see it jump. On expand the -swap is immediate — the open content is there from the first frame, while the box is still growing. - -This means the render function's `isOpen` stays `true` for the duration of the collapse, even though -the `isOpen` prop is already `false`. It reports what is on screen, not what has been requested. - ## Sizing -`size` and `closedSize` set the inline size of each state, in pixels. +The `size` and `closedSize` props accept numbers in pixels or CSS inline-size values. ```tsx + + ``` - + ## Placement -`placement` anchors the content to its edge while the inline size animates, so a right-hand sidebar -collapses toward the right. It also picks the keyboard shortcut (see below). It does **not** position -the box — that is your layout's job. +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. -By default the content shrinks along with the box, which is usually what you want (labels ellipsize, -icons stay put). To have the content hold its width and be clipped from the edge instead — which is -what makes the anchoring visible — give it `flex-shrink: 0` or a fixed inline size. - ## Keyboard -| Key | Action | -| --- | ------------------------------------- | -| `[` | Toggles a `placement="start"` sidebar | -| `]` | Toggles a `placement="end"` sidebar | - -The shortcut is bound on `window`, so it works regardless of focus, and it is ignored while the user -is typing or holding a modifier. It matches the physical key, so it also works on non-Latin layouts. +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. -Set `disableKeyboardShortcut` to turn it off. Note the shortcut assumes at most one sidebar per -`placement` on a page — two would both toggle on the same key. - -## Transition - -Expanding takes 200ms and collapsing 100ms. Reach the underlying transition through -`slotProps.transition`; its `onEntered` and `onExited` fire once the animation has finished. +```tsx + + + +``` - + ## Accessibility -A sidebar is a box that changes size and carries no semantics of its own, so Sidebar adds no `role` -and no ARIA attributes. Supply them yourself: +A Sidebar has no ARIA role by default: -- Use `as="nav"` / `as="aside"` (or pass `role`) and give the region an accessible name with - `aria-label`, so it becomes a landmark. +- 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`. -## Persisting the state - -Sidebar persists nothing. Keep the state where the rest of your app state lives and drive the -component with `isOpen`: - -```tsx -const [isOpen, setIsOpen] = useState( - () => localStorage.getItem('sidebar') === 'open' -); - -const handleOpenChange = (open: boolean) => { - setIsOpen(open); - localStorage.setItem('sidebar', open ? 'open' : 'closed'); -}; - - - {/* … */} -; -``` - ## CSS variables -| Variable | Default | Purpose | -| -------------------------- | --------------------- | ---------------------------------------------------------- | -| `--sidebar-size` | `240px` | Inline size while open (also settable via `size`). | -| `--sidebar-closed-size` | `var(--kbq-size-3xl)` | Inline size while closed (also settable via `closedSize`). | -| `--sidebar-open-duration` | `200ms` | Duration of the expand animation. | -| `--sidebar-close-duration` | `100ms` | Duration of the collapse animation. | +| Variable | Default | Purpose | +| -------------------------- | ------- | ---------------------------------------------------------- | +| `--sidebar-size` | `240px` | Inline size while open (also settable via `size`). | +| `--sidebar-closed-size` | `32px` | Inline size while closed (also settable via `closedSize`). | +| `--sidebar-open-duration` | `200ms` | Duration of the expand animation. | +| `--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 index 0fa4d7d1a..7d6db85d8 100644 --- a/packages/components/src/components/Sidebar/Sidebar.module.css +++ b/packages/components/src/components/Sidebar/Sidebar.module.css @@ -1,6 +1,6 @@ .base { --sidebar-size: 240px; - --sidebar-closed-size: var(--kbq-size-3xl); + --sidebar-closed-size: 32px; --sidebar-open-duration: 200ms; --sidebar-close-duration: 100ms; @@ -8,14 +8,11 @@ overflow: hidden; box-sizing: border-box; block-size: 100%; - - /* The token supplies the easing; the duration is overridden per phase below. */ + interpolate-size: allow-keywords; /* Kept for future intrinsic-size transition support. */ transition: inline-size var(--kbq-transition-slow); transition-duration: var(--sidebar-close-duration); } -/* Anchors the content to its edge while the inline size animates. - `start` is the flexbox default and needs no rule. */ .base[data-placement='end'] { justify-content: flex-end; } diff --git a/packages/components/src/components/Sidebar/Sidebar.stories.module.css b/packages/components/src/components/Sidebar/Sidebar.stories.module.css deleted file mode 100644 index b7ad97d46..000000000 --- a/packages/components/src/components/Sidebar/Sidebar.stories.module.css +++ /dev/null @@ -1,48 +0,0 @@ -.layout { - display: flex; - block-size: 320px; - inline-size: 640px; - border: 1px solid var(--kbq-line-contrast-less); - border-radius: var(--kbq-size-s); - overflow: hidden; -} - -.sidebar { - background-color: var(--kbq-background-bg-tertiary); - flex: none; -} - -.main { - flex: 1 1 auto; - padding: var(--kbq-size-l); -} - -/* `flex: none` keeps the panel at its open width while the sidebar shrinks, so - the content is clipped from the edge instead of reflowing. */ -.panel { - display: flex; - flex: none; - flex-direction: column; - gap: var(--kbq-size-xs); - padding: var(--kbq-size-s); - inline-size: 240px; -} - -.compact { - display: flex; - flex: none; - flex-direction: column; - gap: var(--kbq-size-xs); - padding: var(--kbq-size-xs); - align-items: center; -} - -.item { - display: flex; - align-items: center; - gap: var(--kbq-size-s); - padding: var(--kbq-size-xs) var(--kbq-size-s); - border-radius: var(--kbq-size-xxs); - color: var(--kbq-foreground-contrast); - white-space: nowrap; -} diff --git a/packages/components/src/components/Sidebar/Sidebar.stories.tsx b/packages/components/src/components/Sidebar/Sidebar.stories.tsx index 4eed949c1..d4771fe45 100644 --- a/packages/components/src/components/Sidebar/Sidebar.stories.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.stories.tsx @@ -1,19 +1,28 @@ -import { useState } from 'react'; +import type { CSSProperties } from 'react'; +import { useBoolean } from '@koobiq/react-core'; import { - IconChevronDoubleLeftS16, - IconChevronDoubleRightS16, IconCloud16, IconDashboard16, IconDatabase16, + IconChevronDoubleLeftS16, + IconChevronDoubleRightS16, } from '@koobiq/react-icons'; import type { Meta, StoryObj } from '@storybook/react'; -import { IconButton } from '../IconButton'; +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, sidebarPropPlacement } from './index.js'; -import s from './Sidebar.stories.module.css'; +import { + Sidebar, + type SidebarProps, + type SidebarRenderProps, + sidebarPropPlacement, +} from './index'; const items = [ { icon: , label: 'Dashboard' }, @@ -24,9 +33,6 @@ const items = [ const meta = { title: 'Components/Sidebar', component: Sidebar, - parameters: { - layout: 'centered', - }, argTypes: { placement: { options: sidebarPropPlacement, @@ -39,46 +45,77 @@ const meta = { export default meta; type Story = StoryObj; -const Panel = ({ onCollapse }: { onCollapse: () => void }) => ( -
- - - - {items.map(({ icon, label }) => ( -
- {icon} - {label} -
- ))} -
-); +const containerStyle = { + display: 'flex', + blockSize: 320, + border: '1px solid var(--kbq-line-contrast-less)', +} as CSSProperties; -const Compact = ({ onExpand }: { onExpand: () => void }) => ( -
- - - - {items.map(({ icon, label }) => ( -
- {icon} -
- ))} +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, open, close }) => - isOpen ? : - } +
+ + {({ isOpen, toggle }) => } -
+
Press [ to toggle the sidebar. @@ -87,158 +124,141 @@ export const Base: Story = { ), }; -/** - * Without `isOpen` the sidebar owns its state. `defaultOpen` sets the initial one — - * it is closed by default. - */ -export const Uncontrolled: Story = { - render: (args) => ( -
- - {({ isOpen, open, close }) => - isOpen ? : - } - -
-
- ), -}; - -/** - * Pass `isOpen` and `onOpenChange` to own the state. Note `onOpenChange` fires - * immediately, while the render function keeps reporting the open state until the - * collapse animation has finished. - */ export const Controlled: Story = { render: function Render(args) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, { toggle }] = useBoolean(true); return ( -
+
- {({ isOpen: isOpenState, open, close }) => - isOpenState ? ( - - ) : ( - - ) - } + {({ isOpen, toggle }) => } -
- - The sidebar is {isOpen ? 'open' : 'closed'}. - +
+ The sidebar is {isOpen ? 'open' : 'closed'}. +
); }, }; -/** - * The render function is optional. Plain children stay mounted in both states and - * are simply clipped by the box as it collapses. - */ -export const StaticContent: Story = { - render: (args) => ( -
- -
- {items.map(({ icon, label }) => ( -
- {icon} - {label} -
- ))} -
-
-
- Press [ to toggle. -
-
- ), +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 + + + + + ))} + + ); + }, }; -/** `size` and `closedSize` set the inline size of each state, in pixels. */ -export const Sizing: Story = { - render: (args) => ( -
+export const Placement: Story = { + render: () => ( +
+ {({ isOpen, toggle }) => ( + + )} + +
+ + [ toggles the left sidebar, ] the right one. + +
+ - {({ isOpen, open, close }) => - isOpen ? : - } + {({ isOpen, toggle }) => ( + + )} -
), }; -/** - * `placement` anchors the content to its edge while the inline size animates, and - * picks the shortcut: `[` toggles the `start` sidebar, `]` the `end` one. - */ -export const Placement: Story = { +export const Keyboard: Story = { render: (args) => ( -
- - {({ isOpen, open, close }) => - isOpen ? : - } +
+ + {({ isOpen, toggle }) => } -
+
- [ toggles the left sidebar, ] the right one. + Press B to toggle the sidebar.
- - {({ isOpen, open, close }) => - isOpen ? : - } -
), }; - -/** - * `slotProps.transition` reaches the underlying transition. Its `onEntered` and - * `onExited` fire once the animation has finished — that is the equivalent of the - * Angular `stateChanged` output. - */ -export const CustomTransition: Story = { - render: function Render(args) { - const [log, setLog] = useState('—'); - - return ( -
- setLog('entered'), - onExited: () => setLog('exited'), - }, - }} - > - {({ isOpen, open, close }) => - isOpen ? : - } - -
- Last transition: {log} -
-
- ); - }, -}; diff --git a/packages/components/src/components/Sidebar/Sidebar.test.tsx b/packages/components/src/components/Sidebar/Sidebar.test.tsx index 7ffeefecb..eb6518233 100644 --- a/packages/components/src/components/Sidebar/Sidebar.test.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.test.tsx @@ -215,7 +215,7 @@ describe('Sidebar', () => { ); }); - it('should set the size variables in pixels', () => { + it('should set numeric size variables in pixels', () => { render(); expect(getRoot().style.getPropertyValue('--sidebar-size')).toBe('320px'); @@ -224,6 +224,19 @@ describe('Sidebar', () => { '56px' ); }); + + it.each([ + ['auto', '32px'], + ['50%', '25%'], + ])('should pass CSS size values through: %s and %s', (size, closedSize) => { + render(); + + expect(getRoot().style.getPropertyValue('--sidebar-size')).toBe(size); + + expect(getRoot().style.getPropertyValue('--sidebar-closed-size')).toBe( + closedSize + ); + }); }); describe('check the placement prop', () => { @@ -297,7 +310,51 @@ describe('Sidebar', () => { expect(onOpenChange).toHaveBeenCalledWith(true); }); - it.each(['ctrlKey', 'metaKey', 'altKey'] as const)( + 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 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(); @@ -348,13 +405,13 @@ describe('Sidebar', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); - it('should not listen when disableKeyboardShortcut is set', () => { + it('should not listen when keyboardShortcut is null', () => { const onOpenChange = vi.fn(); render( ); diff --git a/packages/components/src/components/Sidebar/Sidebar.tsx b/packages/components/src/components/Sidebar/Sidebar.tsx index cf1b2fa3f..4b9fe681a 100644 --- a/packages/components/src/components/Sidebar/Sidebar.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.tsx @@ -15,19 +15,15 @@ import { Transition } from 'react-transition-group'; import { TRANSITION_TIMEOUT } from './constants'; import type { SidebarBaseProps } from './index'; import s from './Sidebar.module.css'; -import { isEditableTarget, normalizeSize } from './utils'; +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. - * - * It renders arbitrary content — pass a function as `children` to render a - * different tree per state. It is not an overlay: no backdrop, no focus trap, and - * the content stays mounted while closed. - * - * Sidebar adds no ARIA semantics on its own. Supply them via standard attributes - * (`as="nav"`, `role`, `aria-label`), and mark the toggle control you render with - * `aria-expanded` and `aria-controls`. */ export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>( (props, ref) => { @@ -39,7 +35,7 @@ export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>( size, closedSize, placement = 'start', - disableKeyboardShortcut = false, + keyboardShortcut, slotProps, style, className, @@ -61,16 +57,18 @@ export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>( useEventListener({ eventName: 'keydown', - active: !disableKeyboardShortcut, + active: keyboardShortcut !== null, handler: (event) => { - // Matched by `code`, not `key`: on non-Latin layouts (e.g. ЙЦУКЕН) the - // bracket keys produce other characters and `key` would never match. - const code = placement === 'end' ? 'BracketRight' : 'BracketLeft'; + if (keyboardShortcut === null) return; + + const shortcut = keyboardShortcut ?? { + code: placement === 'end' ? 'BracketRight' : 'BracketLeft', + }; - if (event.code !== code) return; - if (event.ctrlKey || event.metaKey || event.altKey) return; + if (!matchesKeyboardShortcut(event, shortcut)) return; if (isEditableTarget(event.target)) return; + event.preventDefault(); toggle(); }, }); diff --git a/packages/components/src/components/Sidebar/types.ts b/packages/components/src/components/Sidebar/types.ts index c0ede8064..e379c1360 100644 --- a/packages/components/src/components/Sidebar/types.ts +++ b/packages/components/src/components/Sidebar/types.ts @@ -6,6 +6,17 @@ 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; @@ -38,27 +49,30 @@ export type SidebarBaseProps = { /** 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, in pixels. + * 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?: number; + size?: SidebarPropSize; /** - * The inline size of the sidebar while it is closed, in pixels. + * 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?: number; + closedSize?: SidebarPropSize; /** * The side the sidebar is placed on. Anchors the content to that edge while - * the inline size animates, and picks the keyboard shortcut: `start` is - * toggled by `[`, `end` by `]`. + * the inline size animates, and picks the default keyboard shortcut: `start` + * is toggled by `[`, `end` by `]`. * @default 'start' */ placement?: SidebarPropPlacement; /** - * If `true`, the `[` / `]` keyboard shortcut is disabled. - * @default false + * 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 } */ - disableKeyboardShortcut?: boolean; + keyboardShortcut?: SidebarPropKeyboardShortcut | null; /** Additional CSS-classes. */ className?: string; /** Inline styles. */ diff --git a/packages/components/src/components/Sidebar/utils.ts b/packages/components/src/components/Sidebar/utils.ts index fecd9c846..2c6023c8d 100644 --- a/packages/components/src/components/Sidebar/utils.ts +++ b/packages/components/src/components/Sidebar/utils.ts @@ -1,4 +1,19 @@ -export const normalizeSize = (value: number) => `${value}px`; +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 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 000000000..9aa8b13ba --- /dev/null +++ b/tools/public_api_guard/components/Sidebar.api.md @@ -0,0 +1,69 @@ +## 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 { ReactElement } from 'react'; +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) => ReactElement); + +// @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) + +``` diff --git a/vite.config.mts b/vite.config.mts index 1e8beb0a2..aed2ea060 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -26,11 +26,7 @@ export const css: UserConfig['css'] = { const prefix = 'kbq'; const parts = [prefix]; - // A dot would make the generated name parse as a compound selector - // (`.a.b`) and never match the element, e.g. `Flag.stories.module.css`. - const fileName = path - .basename(filename, '.module.css') - .replace(/\./g, '-'); + const fileName = path.basename(filename, '.module.css'); parts.push(fileName.toLowerCase()); From 34f4c4c8cc0628d4126318d4108650292c07f1b0 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 15 Jul 2026 18:21:01 +0300 Subject: [PATCH 3/5] chore(Sidebar): fix css-vars --- .../src/components/Sidebar/Sidebar.mdx | 12 ++++++------ .../src/components/Sidebar/Sidebar.module.css | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/components/src/components/Sidebar/Sidebar.mdx b/packages/components/src/components/Sidebar/Sidebar.mdx index 23ed4ee58..244df0cfb 100644 --- a/packages/components/src/components/Sidebar/Sidebar.mdx +++ b/packages/components/src/components/Sidebar/Sidebar.mdx @@ -90,9 +90,9 @@ A Sidebar has no ARIA role by default: ## CSS variables -| Variable | Default | Purpose | -| -------------------------- | ------- | ---------------------------------------------------------- | -| `--sidebar-size` | `240px` | Inline size while open (also settable via `size`). | -| `--sidebar-closed-size` | `32px` | Inline size while closed (also settable via `closedSize`). | -| `--sidebar-open-duration` | `200ms` | Duration of the expand animation. | -| `--sidebar-close-duration` | `100ms` | Duration of the collapse animation. | +| 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 index 7d6db85d8..73f5f7b17 100644 --- a/packages/components/src/components/Sidebar/Sidebar.module.css +++ b/packages/components/src/components/Sidebar/Sidebar.module.css @@ -1,8 +1,8 @@ .base { - --sidebar-size: 240px; - --sidebar-closed-size: 32px; - --sidebar-open-duration: 200ms; - --sidebar-close-duration: 100ms; + --kbq-sidebar-size: 240px; + --kbq-sidebar-closed-size: 32px; + --kbq-sidebar-open-duration: 200ms; + --kbq-sidebar-close-duration: 100ms; display: flex; overflow: hidden; @@ -10,7 +10,7 @@ block-size: 100%; interpolate-size: allow-keywords; /* Kept for future intrinsic-size transition support. */ transition: inline-size var(--kbq-transition-slow); - transition-duration: var(--sidebar-close-duration); + transition-duration: var(--kbq-sidebar-close-duration); } .base[data-placement='end'] { @@ -19,15 +19,15 @@ /* animation */ .base[data-transition='entering'] { - transition-duration: var(--sidebar-open-duration); + transition-duration: var(--kbq-sidebar-open-duration); } .base[data-transition='entering'], .base[data-transition='entered'] { - inline-size: var(--sidebar-size); + inline-size: var(--kbq-sidebar-size); } .base[data-transition='exiting'], .base[data-transition='exited'] { - inline-size: var(--sidebar-closed-size); + inline-size: var(--kbq-sidebar-closed-size); } From 09348152abb46fc7e8b49d830812906f2d08f051 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 15 Jul 2026 18:37:25 +0300 Subject: [PATCH 4/5] chore(Sidebar): fix css-vars (round 2) --- .../src/components/Sidebar/Sidebar.test.tsx | 31 +++++++++++-------- .../src/components/Sidebar/Sidebar.tsx | 8 ++--- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/components/src/components/Sidebar/Sidebar.test.tsx b/packages/components/src/components/Sidebar/Sidebar.test.tsx index eb6518233..23394fed6 100644 --- a/packages/components/src/components/Sidebar/Sidebar.test.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.test.tsx @@ -41,7 +41,10 @@ describe('Sidebar', () => { it('should merge the consumer style with the component variables', () => { render(); - expect(getRoot().style.getPropertyValue('--sidebar-size')).toBe('240px'); + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe( + '240px' + ); + expect(getRoot().style.color).toBe('red'); }); @@ -208,21 +211,23 @@ describe('Sidebar', () => { it('should set no size variables by default', () => { render(); - expect(getRoot().style.getPropertyValue('--sidebar-size')).toBe(''); + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe(''); - expect(getRoot().style.getPropertyValue('--sidebar-closed-size')).toBe( - '' - ); + expect( + getRoot().style.getPropertyValue('--kbq-sidebar-closed-size') + ).toBe(''); }); it('should set numeric size variables in pixels', () => { render(); - expect(getRoot().style.getPropertyValue('--sidebar-size')).toBe('320px'); - - expect(getRoot().style.getPropertyValue('--sidebar-closed-size')).toBe( - '56px' + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe( + '320px' ); + + expect( + getRoot().style.getPropertyValue('--kbq-sidebar-closed-size') + ).toBe('56px'); }); it.each([ @@ -231,11 +236,11 @@ describe('Sidebar', () => { ])('should pass CSS size values through: %s and %s', (size, closedSize) => { render(); - expect(getRoot().style.getPropertyValue('--sidebar-size')).toBe(size); + expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe(size); - expect(getRoot().style.getPropertyValue('--sidebar-closed-size')).toBe( - closedSize - ); + expect( + getRoot().style.getPropertyValue('--kbq-sidebar-closed-size') + ).toBe(closedSize); }); }); diff --git a/packages/components/src/components/Sidebar/Sidebar.tsx b/packages/components/src/components/Sidebar/Sidebar.tsx index 4b9fe681a..5fe6e3907 100644 --- a/packages/components/src/components/Sidebar/Sidebar.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.tsx @@ -75,12 +75,12 @@ export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>( const sidebarStyle = { ...style, - ...(size !== undefined && { '--sidebar-size': normalizeSize(size) }), + ...(size !== undefined && { '--kbq-sidebar-size': normalizeSize(size) }), ...(closedSize !== undefined && { - '--sidebar-closed-size': normalizeSize(closedSize), + '--kbq-sidebar-closed-size': normalizeSize(closedSize), }), - '--sidebar-open-duration': `${TRANSITION_TIMEOUT.enter}ms`, - '--sidebar-close-duration': `${TRANSITION_TIMEOUT.exit}ms`, + '--kbq-sidebar-open-duration': `${TRANSITION_TIMEOUT.enter}ms`, + '--kbq-sidebar-close-duration': `${TRANSITION_TIMEOUT.exit}ms`, } as CSSProperties; const transitionProps = { From af97263fdcd1e9f4a61f49b9fa9fa4504e69a076 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 16 Jul 2026 15:57:41 +0300 Subject: [PATCH 5/5] chore(Sidebar): address review feedback --- .../components/Sidebar/Sidebar.stories.tsx | 4 +- .../src/components/Sidebar/Sidebar.test.tsx | 17 +++ .../src/components/Sidebar/Sidebar.tsx | 2 +- .../src/components/Sidebar/types.ts | 4 +- .../components/Sidebar.api.md | 137 +++++++++--------- 5 files changed, 90 insertions(+), 74 deletions(-) diff --git a/packages/components/src/components/Sidebar/Sidebar.stories.tsx b/packages/components/src/components/Sidebar/Sidebar.stories.tsx index d4771fe45..fe1e830b0 100644 --- a/packages/components/src/components/Sidebar/Sidebar.stories.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.stories.tsx @@ -126,7 +126,7 @@ export const Base: Story = { export const Controlled: Story = { render: function Render(args) { - const [isOpen, { toggle }] = useBoolean(true); + const [isOpen, { toggle, set }] = useBoolean(true); return (
@@ -134,7 +134,7 @@ export const Controlled: Story = { closedSize={40} isOpen={isOpen} style={sidebarStyle} - onOpenChange={toggle} + onOpenChange={set} {...args} > {({ isOpen, toggle }) => } diff --git a/packages/components/src/components/Sidebar/Sidebar.test.tsx b/packages/components/src/components/Sidebar/Sidebar.test.tsx index 23394fed6..4867b531a 100644 --- a/packages/components/src/components/Sidebar/Sidebar.test.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.test.tsx @@ -73,6 +73,12 @@ describe('Sidebar', () => { 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); @@ -333,6 +339,17 @@ describe('Sidebar', () => { 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(); diff --git a/packages/components/src/components/Sidebar/Sidebar.tsx b/packages/components/src/components/Sidebar/Sidebar.tsx index 5fe6e3907..e7acf8287 100644 --- a/packages/components/src/components/Sidebar/Sidebar.tsx +++ b/packages/components/src/components/Sidebar/Sidebar.tsx @@ -59,7 +59,7 @@ export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>( eventName: 'keydown', active: keyboardShortcut !== null, handler: (event) => { - if (keyboardShortcut === null) return; + if (keyboardShortcut === null || event.repeat) return; const shortcut = keyboardShortcut ?? { code: placement === 'end' ? 'BracketRight' : 'BracketLeft', diff --git a/packages/components/src/components/Sidebar/types.ts b/packages/components/src/components/Sidebar/types.ts index e379c1360..5e69035f5 100644 --- a/packages/components/src/components/Sidebar/types.ts +++ b/packages/components/src/components/Sidebar/types.ts @@ -1,4 +1,4 @@ -import type { CSSProperties, ReactElement, ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import type { TransitionProps } from 'react-transition-group/Transition'; @@ -30,7 +30,7 @@ export type SidebarRenderProps = { export type SidebarPropContent = | ReactNode - | ((props: SidebarRenderProps) => ReactElement); + | ((props: SidebarRenderProps) => ReactNode); export type SidebarBaseProps = { /** diff --git a/tools/public_api_guard/components/Sidebar.api.md b/tools/public_api_guard/components/Sidebar.api.md index 9aa8b13ba..e048c2d2c 100644 --- a/tools/public_api_guard/components/Sidebar.api.md +++ b/tools/public_api_guard/components/Sidebar.api.md @@ -1,69 +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 { ReactElement } from 'react'; -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) => ReactElement); - -// @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) - -``` +## 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) + +```