From db739ad989cad2b583d35cd5e7b27dcdb448cda3 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 31 Jul 2026 18:31:02 +0300 Subject: [PATCH 1/3] feat(TopBar): add initial draft implementation --- .../src/components/TopBar/TopBar.mdx | 125 +++++++ .../src/components/TopBar/TopBar.module.css | 40 ++ .../src/components/TopBar/TopBar.stories.tsx | 348 ++++++++++++++++++ .../src/components/TopBar/TopBar.test.tsx | 235 ++++++++++++ .../src/components/TopBar/TopBar.tsx | 53 +++ .../TopBar/__stories__/styles.module.css | 6 + .../TopBarContainer.module.css | 26 ++ .../TopBarContainer/TopBarContainer.tsx | 64 ++++ .../components/TopBarContainer/index.ts | 2 + .../components/TopBarContainer/types.ts | 32 ++ .../TopBarTitle/TopBarTitle.module.css | 12 + .../components/TopBarTitle/TopBarTitle.tsx | 30 ++ .../TopBar/components/TopBarTitle/index.ts | 2 + .../TopBar/components/TopBarTitle/types.ts | 15 + .../src/components/TopBar/components/index.ts | 2 + .../components/src/components/TopBar/index.ts | 3 + .../components/src/components/TopBar/types.ts | 31 ++ packages/components/src/components/index.ts | 1 + tools/api-extractor/config.json | 1 + .../public_api_guard/components/TopBar.api.md | 75 ++++ 20 files changed, 1103 insertions(+) create mode 100644 packages/components/src/components/TopBar/TopBar.mdx create mode 100644 packages/components/src/components/TopBar/TopBar.module.css create mode 100644 packages/components/src/components/TopBar/TopBar.stories.tsx create mode 100644 packages/components/src/components/TopBar/TopBar.test.tsx create mode 100644 packages/components/src/components/TopBar/TopBar.tsx create mode 100644 packages/components/src/components/TopBar/__stories__/styles.module.css create mode 100644 packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css create mode 100644 packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx create mode 100644 packages/components/src/components/TopBar/components/TopBarContainer/index.ts create mode 100644 packages/components/src/components/TopBar/components/TopBarContainer/types.ts create mode 100644 packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css create mode 100644 packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.tsx create mode 100644 packages/components/src/components/TopBar/components/TopBarTitle/index.ts create mode 100644 packages/components/src/components/TopBar/components/TopBarTitle/types.ts create mode 100644 packages/components/src/components/TopBar/components/index.ts create mode 100644 packages/components/src/components/TopBar/index.ts create mode 100644 packages/components/src/components/TopBar/types.ts create mode 100644 tools/public_api_guard/components/TopBar.api.md diff --git a/packages/components/src/components/TopBar/TopBar.mdx b/packages/components/src/components/TopBar/TopBar.mdx new file mode 100644 index 00000000..449a6d0a --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.mdx @@ -0,0 +1,125 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './TopBar.stories'; + + + +# TopBar + + + +TopBar is the bar at the top of a page. It shows the page title or the breadcrumbs on the start side +and the page actions on the end side. + +## Import + +```tsx +import { TopBar } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Anatomy + +The bar holds one container per side. Render only the ones you need. + +```tsx + + + Dashboards + + + + + + +``` + +- `TopBar.Container` — one side of the bar. `placement="start"` grows and shrinks first, + `placement="end"` sticks to the opposite edge. +- `TopBar.Title` — the page heading. It renders an `h1` and cuts long text with an ellipsis. + +TopBar keeps at least `--kbq-top-bar-gap` (80px) between the two sides. + +## Logo and counter + +A logo and an object counter are plain content of the start container — TopBar has no slots for them. + + + +## Position + +Use `position` to choose how the bar sits on the page: + +- `sticky` (default) — the bar pins to the top of the nearest scrolling ancestor. +- `static` — the bar stays in the flow. + +The bar always stays in the flow, so the page content never needs a top offset. + + + +## Shadow + +The `hasShadow` only paints the bottom shadow. TopBar never listens to scrolling — watch the scroll +position yourself and pass the result in. + + + +## Breadcrumbs + +Put `Breadcrumbs` in the start container. They collapse on their own — you do not need to add any +logic around them. + + + +## Collapsing actions + +Collapsing the actions into a "…" menu is not built into TopBar. Build it with `useHideOverflowItems` +and a `Menu`, so you stay in control of which action collapses first. + + + +## Shrinking + +The start side gives up its space first and the actions keep their size, so a button label never +turns into an unreadable stub: + +1. The title is cut with an ellipsis down to `--kbq-top-bar-title-min-inline-size`. Breadcrumbs + collapse their middle items, then everything but the last one. +2. Once the start side has nothing left to give, the bar runs out of room. That is the point to + collapse the actions into a "…" menu — see [Collapsing actions](#collapsing-actions). + +## CSS variables + +| Variable | Default | Purpose | +| ------------------------------------- | ------------------------------------------ | --------------------------------------------------- | +| `--kbq-top-bar-background` | `var(--kbq-background-bg)` | Background of the bar. | +| `--kbq-top-bar-padding-block` | `var(--kbq-size-m)` | Block padding. | +| `--kbq-top-bar-padding-inline` | `var(--kbq-size-xxl)` | Inline padding. | +| `--kbq-top-bar-border-radius` | `0` | Border radius. | +| `--kbq-top-bar-gap` | `80px` | Minimum distance between the two sides. | +| `--kbq-top-bar-z-index` | `calc(var(--kbq-layer-modal) - 1)` | Stack order while `sticky`. | +| `--kbq-top-bar-shadow` | `var(--kbq-shadow-overflow-normal-bottom)` | Shadow shown by `hasShadow`. | +| `--kbq-top-bar-container-start-gap` | `0` | Gap between items in the start container. | +| `--kbq-top-bar-container-end-gap` | `var(--kbq-size-s)` | Gap between items in the end container. | +| `--kbq-top-bar-title-min-inline-size` | `4ch` | Smallest width of the title before the end shrinks. | + +## Accessibility + +- TopBar renders a `header`. Use `as` when the bar is not the banner of the page. +- `TopBar.Title` renders an `h1`. Use `as` to fit the heading order of your page. +- Set `isToolbar` on the container with the actions. It adds `role="toolbar"` and lets the user move + between the actions with the arrow keys, so the whole group takes one Tab stop. +- Give a toolbar container an `aria-label`, for example `aria-label="Page actions"`. +- Give icon-only actions an `aria-label`. diff --git a/packages/components/src/components/TopBar/TopBar.module.css b/packages/components/src/components/TopBar/TopBar.module.css new file mode 100644 index 00000000..68d67c32 --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.module.css @@ -0,0 +1,40 @@ +.base { + --kbq-top-bar-background: var(--kbq-background-bg); + --kbq-top-bar-padding-block: var(--kbq-size-m); + --kbq-top-bar-padding-inline: var(--kbq-size-xxl); + --kbq-top-bar-border-radius: 0; + + /* The minimum distance between the start and the end containers. */ + --kbq-top-bar-gap: 80px; + + /* Above the page content, below modals and toasts. */ + --kbq-top-bar-z-index: calc(var(--kbq-layer-modal) - 1); + --kbq-top-bar-shadow: var(--kbq-shadow-overflow-normal-bottom); + --kbq-top-bar-container-start-gap: 0; + --kbq-top-bar-container-end-gap: var(--kbq-size-s); + + /* Roughly three characters plus an ellipsis. */ + --kbq-top-bar-title-min-inline-size: 4ch; + + display: flex; + box-sizing: border-box; + align-items: center; + gap: var(--kbq-top-bar-gap); + padding-block: var(--kbq-top-bar-padding-block); + padding-inline: var(--kbq-top-bar-padding-inline); + border-radius: var(--kbq-top-bar-border-radius); + background: var(--kbq-top-bar-background); + transition: box-shadow var(--kbq-transition-slow); +} + +/* position */ +.base[data-position='sticky'] { + position: sticky; + z-index: var(--kbq-top-bar-z-index); + inset-block-start: 0; +} + +/* shadow */ +.base[data-shadow='true'] { + box-shadow: var(--kbq-top-bar-shadow); +} diff --git a/packages/components/src/components/TopBar/TopBar.stories.tsx b/packages/components/src/components/TopBar/TopBar.stories.tsx new file mode 100644 index 00000000..f6c66dbd --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.stories.tsx @@ -0,0 +1,348 @@ +import { type CSSProperties, type SVGProps, useState } from 'react'; + +import { mergeRefs, useHideOverflowItems } from '@koobiq/react-core'; +import { + IconArrowsRotate16, + IconBug16, + IconEllipsisHorizontal16, + IconFilter16, + IconGear16, + IconList16, + IconMagnifyingGlass16, + IconPlus16, + IconPrinter16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { BreadcrumbItem, Breadcrumbs } from '../Breadcrumbs'; +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { IconButton } from '../IconButton'; +import { spacing } from '../layout'; +import { Menu } from '../Menu'; +import { Typography } from '../Typography'; + +import s from './__stories__/styles.module.css'; +import { TopBar, type TopBarProps, topBarPropPosition } from './index.js'; + +const meta = { + title: 'Components/TopBar', + component: TopBar, + subcomponents: { + 'TopBar.Container': TopBar.Container, + 'TopBar.Title': TopBar.Title, + }, + parameters: { + layout: 'padded', + }, + tags: ['status:new', 'date:2026-07-31'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const AppIcon = (props: SVGProps) => ( + + + + +); + +const Content = () => ( + + + Web security is a crucial aspect of modern digital infrastructure, + ensuring the protection of sensitive data, user privacy, and system + integrity. As cyber threats continue to evolve, developers and + organizations must adopt a proactive approach to securing web applications + against attacks. + + + One of the most common vulnerabilities is SQL injection, where attackers + manipulate database queries to gain unauthorized access to sensitive + information. Similarly, cross-site scripting (XSS) allows malicious + scripts to run on a victim’s browser, leading to data theft or session + hijacking. Another prevalent threat is cross-site request forgery (CSRF), + in which users are tricked into executing unwanted actions on + authenticated sites. Additionally, man-in-the-middle attacks intercept + communication between users and servers, compromising the confidentiality + of data. Distributed Denial-of-Service (DDoS) attacks can also cripple web + services by overwhelming them with excessive traffic. + + + To mitigate these risks, implementing strong security practices is + essential. Using HTTPS ensures encrypted communication, protecting data + from interception. Proper input validation and escaping mechanisms help + prevent code injection attacks. Authentication and authorization + mechanisms, including multi-factor authentication (MFA) and role-based + access control (RBAC), add layers of security to user access. Secure API + development, including authentication, rate limiting, and encryption, + reduces vulnerabilities in web services. Keeping software, frameworks, and + dependencies up to date minimizes the risk of exploiting known + vulnerabilities. Continuous monitoring, logging, and security audits help + detect and respond to threats before they cause significant damage. + + + Web security is not a one-time implementation but an ongoing process that + evolves alongside emerging threats. By following best practices and + staying vigilant, businesses and developers can build resilient, secure + applications that protect users and data in an increasingly connected + world. + + +); + +export const Base: Story = { + render: (args) => ( + + + Dashboards + + + + + + + ), +}; + +export const WithLogoAndCounter: Story = { + render: (args) => ( + + + + + + + Dashboards + + + 10 + + + + + + + ), +}; + +export const Position: Story = { + render: function Render() { + const containerStyle = { + overflow: 'auto', + position: 'relative', + blockSize: 240, + borderRadius: 'var(--kbq-size-s)', + border: '1px solid var(--kbq-line-contrast-less)', + } as CSSProperties; + + return ( + + {topBarPropPosition.map((position) => ( + + + position = {position} + +
+ + + Dashboards + + + + + + +
+
+ ))} +
+ ); + }, +}; + +export const Shadow: Story = { + render: function Render(args) { + const [isScrolled, setIsScrolled] = useState(false); + + const containerStyle = { + overflow: 'auto', + blockSize: 320, + borderRadius: 'var(--kbq-size-s)', + border: '1px solid var(--kbq-line-contrast-less)', + } as CSSProperties; + + return ( +
setIsScrolled(event.currentTarget.scrollTop > 0)} + > + + + Dashboards + + + + + + +
+ ); + }, +}; + +export const WithBreadcrumbs: Story = { + render: (args) => ( + + + + + + + Main + Section + Details + Pipeline + + + + + + + + + + + + + ), +}; + +export const CollapsedActions: Story = { + render: function Render(args) { + const actions = [ + { key: 'refresh', label: 'Refresh', icon: }, + { key: 'view', label: 'Change view', icon: }, + { key: 'filter', label: 'Filter', icon: }, + { key: 'print', label: 'Print the page', icon: }, + { key: 'settings', label: 'Page settings', icon: }, + { key: 'report', label: 'Report a bug', icon: }, + ]; + + /** + * Everything on the bar that the actions may not use: its padding, the gap + * between the two sides, and the room the breadcrumbs keep once they are fully + * collapsed. The actions start moving into the "…" menu below this. + */ + const RESERVED_SPACE = 250; + + const moreIndex = actions.length; + + // `parentRef` goes on the bar, not on the actions container: the bar keeps + // its width when an action is hidden, so hiding one cannot trigger hiding + // the next. `busy` is the part of that width the actions may not use. + const { parentRef, visibleMap, itemsRefs } = useHideOverflowItems< + HTMLButtonElement, + HTMLElement + >({ + length: actions.length + 1, + moreIndex, + busy: RESERVED_SPACE, + }); + + const collapsedActions = actions.filter((_, index) => !visibleMap[index]); + + return ( + + + + + + + Main + Section + Details + Pipeline + + + + {actions.map((action, index) => ( + + {action.icon} + + ))} + ( + + + + )} + placement="bottom end" + > + {collapsedActions.map((action) => ( + + {action.icon} + {action.label} + + ))} + + + + ); + }, +}; diff --git a/packages/components/src/components/TopBar/TopBar.test.tsx b/packages/components/src/components/TopBar/TopBar.test.tsx new file mode 100644 index 00000000..a6b2afdd --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.test.tsx @@ -0,0 +1,235 @@ +import { createRef } from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { + TopBar, + topBarPropPosition, + topBarContainerPropPlacement, +} from './index.js'; + +describe('TopBar', () => { + const baseProps = { 'data-testid': 'top-bar' }; + + const getRoot = () => screen.getByTestId('top-bar'); + + it('should accept the ref', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBe(getRoot()); + }); + + it('should render as a header by default', () => { + render(); + + expect(getRoot().tagName).toBe('HEADER'); + }); + + it('should support the polymorphic "as" prop', () => { + render(); + + expect(getRoot().tagName).toBe('DIV'); + }); + + it('should merge a custom class name with the default ones', () => { + render(); + + expect(getRoot()).toHaveClass('foo'); + }); + + it('should merge the consumer style', () => { + render(); + + expect(getRoot().style.color).toBe('red'); + }); + + it('should spread additional HTML props onto the root element', () => { + render(); + + const root = getRoot(); + + expect(root).toHaveAttribute('id', 'page-top-bar'); + expect(root).toHaveAttribute('aria-label', 'Page'); + }); + + it('should render the subcomponents', () => { + render( + + + Dashboards + + + + + + ); + + expect(screen.getByTestId('start')).toBeInTheDocument(); + expect(screen.getByTestId('end')).toBeInTheDocument(); + expect(screen.getByTestId('title')).toHaveTextContent('Dashboards'); + }); + + describe('check the position prop', () => { + it('should be sticky by default', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-position', 'sticky'); + }); + + it.each(topBarPropPosition)( + 'should apply the position "%s"', + (position) => { + render(); + + expect(getRoot()).toHaveAttribute('data-position', position); + } + ); + }); + + describe('check the hasShadow prop', () => { + it('should hide the shadow by default', () => { + render(); + + expect(getRoot()).not.toHaveAttribute('data-shadow'); + }); + + it('should set data-shadow when hasShadow is true', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-shadow', 'true'); + }); + }); +}); + +describe('TopBar.Container', () => { + const baseProps = { 'data-testid': 'container' }; + + const getRoot = () => screen.getByTestId('container'); + + it('should accept the ref', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBe(getRoot()); + }); + + it('should merge a custom class name with the default ones', () => { + render(); + + expect(getRoot()).toHaveClass('foo'); + }); + + it('should support the polymorphic "as" prop', () => { + render(); + + expect(getRoot().tagName).toBe('NAV'); + }); + + it('should be placed at the start by default', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-placement', 'start'); + }); + + it.each(topBarContainerPropPlacement)( + 'should apply the placement "%s"', + (placement) => { + render(); + + expect(getRoot()).toHaveAttribute('data-placement', placement); + } + ); + + describe('check the isToolbar prop', () => { + it('should add no toolbar semantics by default', () => { + render(); + + const container = getRoot(); + + expect(container).not.toHaveAttribute('role'); + expect(container).not.toHaveAttribute('aria-orientation'); + }); + + it('should add the toolbar semantics when isToolbar is true', () => { + render( + + ); + + const container = getRoot(); + + expect(container).toHaveAttribute('role', 'toolbar'); + expect(container).toHaveAttribute('aria-orientation', 'horizontal'); + expect(container).toHaveAttribute('aria-label', 'Page actions'); + }); + + it('should keep aria-label without the toolbar semantics', () => { + render(); + + expect(getRoot()).toHaveAttribute('aria-label', 'Page actions'); + }); + + it('should move focus between the actions with the arrow keys', async () => { + render( + + + + + ); + + const share = screen.getByRole('button', { name: 'Share' }); + const apply = screen.getByRole('button', { name: 'Apply' }); + + await userEvent.tab(); + + expect(share).toHaveFocus(); + + await userEvent.keyboard('{ArrowRight}'); + + expect(apply).toHaveFocus(); + + await userEvent.keyboard('{ArrowLeft}'); + + expect(share).toHaveFocus(); + }); + }); +}); + +describe('TopBar.Title', () => { + const baseProps = { 'data-testid': 'title' }; + + const getRoot = () => screen.getByTestId('title'); + + it('should accept the ref', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBe(getRoot()); + }); + + it('should render as an h1 with the content by default', () => { + render(Dashboards); + + const title = getRoot(); + + expect(title.tagName).toBe('H1'); + expect(title).toHaveTextContent('Dashboards'); + }); + + it('should support the polymorphic "as" prop', () => { + render(); + + expect(getRoot().tagName).toBe('H2'); + }); + + it('should merge a custom class name with the default ones', () => { + render(); + + expect(getRoot()).toHaveClass('foo'); + }); +}); diff --git a/packages/components/src/components/TopBar/TopBar.tsx b/packages/components/src/components/TopBar/TopBar.tsx new file mode 100644 index 00000000..692c3728 --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.tsx @@ -0,0 +1,53 @@ +'use client'; + +import type { ComponentPropsWithRef, ElementType } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import { TopBarContainer, TopBarTitle } from './components'; +import s from './TopBar.module.css'; +import type { TopBarBaseProps } from './types'; + +const TopBarComponent = polymorphicForwardRef<'header', TopBarBaseProps>( + (props, ref) => { + const { + as: Tag = 'header', + position = 'sticky', + hasShadow = false, + className, + children, + ...other + } = props; + + return ( + + {children} + + ); + } +); + +TopBarComponent.displayName = 'TopBar'; + +type CompoundedComponent = typeof TopBarComponent & { + Container: typeof TopBarContainer; + Title: typeof TopBarTitle; +}; + +/** + * TopBar is the bar at the top of a page. It holds the page title or the + * breadcrumbs on the start side and the page actions on the end side. + */ +export const TopBar = TopBarComponent as CompoundedComponent; + +TopBar.Container = TopBarContainer; +TopBar.Title = TopBarTitle; + +export type TopBarProps = + ComponentPropsWithRef>; diff --git a/packages/components/src/components/TopBar/__stories__/styles.module.css b/packages/components/src/components/TopBar/__stories__/styles.module.css new file mode 100644 index 00000000..25c168e7 --- /dev/null +++ b/packages/components/src/components/TopBar/__stories__/styles.module.css @@ -0,0 +1,6 @@ +/* Keeps a collapsed action measurable while taking it out of the layout. */ +.hiddenAction[aria-hidden='true'] { + visibility: hidden; + position: absolute; + inset-inline-start: -300vw; +} diff --git a/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css new file mode 100644 index 00000000..61ae70f5 --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css @@ -0,0 +1,26 @@ +.base { + display: flex; + align-items: center; + min-inline-size: 0; +} + +/* placement */ + +/* The start side gives up all the space: the title is cut and the breadcrumbs + collapse, while the actions keep their size. Once the start side has nothing + left to give, collapse the actions yourself — see the docs. */ +.base[data-placement='start'] { + flex: 1 1 auto; + gap: var(--kbq-top-bar-container-start-gap); +} + +.base[data-placement='end'] { + flex: 0 0 auto; + justify-content: flex-end; + gap: var(--kbq-top-bar-container-end-gap); +} + +/* Keeps the container at the end edge when there is no start container. */ +.base[data-placement='end']:first-child { + margin-inline-start: auto; +} diff --git a/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx new file mode 100644 index 00000000..1f2a8fda --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx @@ -0,0 +1,64 @@ +'use client'; + +import type { ComponentPropsWithRef, ElementType } from 'react'; + +import { + clsx, + mergeProps, + polymorphicForwardRef, + useObjectRef, +} from '@koobiq/react-core'; +import { useToolbar } from '@koobiq/react-primitives'; + +import s from './TopBarContainer.module.css'; +import type { TopBarContainerBaseProps } from './types'; + +/** TopBar.Container groups the content on one side of the top bar. */ +export const TopBarContainer = polymorphicForwardRef< + 'div', + TopBarContainerBaseProps +>((props, ref) => { + const { + as: Tag = 'div', + placement = 'start', + isToolbar = false, + className, + children, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + ...other + } = props; + + const containerRef = useObjectRef(ref); + + const { toolbarProps } = useToolbar( + { + orientation: 'horizontal', + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + }, + containerRef + ); + + const rootProps = mergeProps( + { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'data-placement': placement, + className: clsx(s.base, className), + }, + other, + isToolbar ? toolbarProps : {} + ); + + return ( + + {children} + + ); +}); + +TopBarContainer.displayName = 'TopBar.Container'; + +export type TopBarContainerProps = + ComponentPropsWithRef>; diff --git a/packages/components/src/components/TopBar/components/TopBarContainer/index.ts b/packages/components/src/components/TopBar/components/TopBarContainer/index.ts new file mode 100644 index 00000000..ad410002 --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarContainer/index.ts @@ -0,0 +1,2 @@ +export * from './TopBarContainer'; +export * from './types'; diff --git a/packages/components/src/components/TopBar/components/TopBarContainer/types.ts b/packages/components/src/components/TopBar/components/TopBarContainer/types.ts new file mode 100644 index 00000000..ad93f26e --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarContainer/types.ts @@ -0,0 +1,32 @@ +import type { ElementType, ReactNode } from 'react'; + +import type { AriaLabelingProps, DataAttributeProps } from '@koobiq/react-core'; + +export const topBarContainerPropPlacement = ['start', 'end'] as const; + +export type TopBarContainerPropPlacement = + (typeof topBarContainerPropPlacement)[number]; + +export type TopBarContainerBaseProps = { + /** + * The side of the top bar the container is placed on. + * @default 'start' + */ + placement?: TopBarContainerPropPlacement; + /** + * Whether the container is a toolbar. Adds `role="toolbar"` and lets the user + * move between the actions with the arrow keys. Give the container an + * `aria-label` when it is a toolbar. + */ + isToolbar?: boolean; + /** + * The HTML element to render as. + * @default 'div' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the container. */ + children?: ReactNode; +} & AriaLabelingProps & + DataAttributeProps; diff --git a/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css b/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css new file mode 100644 index 00000000..d344aece --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css @@ -0,0 +1,12 @@ +@import url('../../../../styles/mixins.css'); + +.base { + @mixin ellipsis; + + min-inline-size: var(--kbq-top-bar-title-min-inline-size); + color: var(--kbq-foreground-contrast); +} + +:where(.base) { + margin: unset; +} diff --git a/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.tsx b/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.tsx new file mode 100644 index 00000000..10bef597 --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.tsx @@ -0,0 +1,30 @@ +'use client'; + +import type { ComponentPropsWithRef, ElementType } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import { utilClasses } from '../../../../styles/utility'; + +import s from './TopBarTitle.module.css'; +import type { TopBarTitleBaseProps } from './types'; + +const { title } = utilClasses.typography; + +/** TopBar.Title is the page heading shown on the start side of the top bar. */ +export const TopBarTitle = polymorphicForwardRef<'h1', TopBarTitleBaseProps>( + (props, ref) => { + const { as: Tag = 'h1', className, children, ...other } = props; + + return ( + + {children} + + ); + } +); + +TopBarTitle.displayName = 'TopBar.Title'; + +export type TopBarTitleProps = + ComponentPropsWithRef>; diff --git a/packages/components/src/components/TopBar/components/TopBarTitle/index.ts b/packages/components/src/components/TopBar/components/TopBarTitle/index.ts new file mode 100644 index 00000000..2d039472 --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarTitle/index.ts @@ -0,0 +1,2 @@ +export * from './TopBarTitle'; +export * from './types'; diff --git a/packages/components/src/components/TopBar/components/TopBarTitle/types.ts b/packages/components/src/components/TopBar/components/TopBarTitle/types.ts new file mode 100644 index 00000000..82ea5dcb --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarTitle/types.ts @@ -0,0 +1,15 @@ +import type { ElementType, ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type TopBarTitleBaseProps = { + /** + * The HTML element to render as. + * @default 'h1' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the title. */ + children?: ReactNode; +} & DataAttributeProps; diff --git a/packages/components/src/components/TopBar/components/index.ts b/packages/components/src/components/TopBar/components/index.ts new file mode 100644 index 00000000..337f8d87 --- /dev/null +++ b/packages/components/src/components/TopBar/components/index.ts @@ -0,0 +1,2 @@ +export * from './TopBarContainer'; +export * from './TopBarTitle'; diff --git a/packages/components/src/components/TopBar/index.ts b/packages/components/src/components/TopBar/index.ts new file mode 100644 index 00000000..5bb1e465 --- /dev/null +++ b/packages/components/src/components/TopBar/index.ts @@ -0,0 +1,3 @@ +export * from './TopBar'; +export * from './types'; +export * from './components'; diff --git a/packages/components/src/components/TopBar/types.ts b/packages/components/src/components/TopBar/types.ts new file mode 100644 index 00000000..5c824dc7 --- /dev/null +++ b/packages/components/src/components/TopBar/types.ts @@ -0,0 +1,31 @@ +import type { ElementType, ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export const topBarPropPosition = ['static', 'sticky'] as const; + +export type TopBarPropPosition = (typeof topBarPropPosition)[number]; + +export type TopBarBaseProps = { + /** + * How the top bar is positioned on the page. + * `sticky` pins it to the top of the nearest scrolling ancestor, `static` + * keeps it in the flow. + * @default 'sticky' + */ + position?: TopBarPropPosition; + /** + * Whether to show the bottom shadow. The component never listens to scrolling + * on its own — toggle this prop when the page is scrolled. + */ + hasShadow?: boolean; + /** + * The HTML element to render as. + * @default 'header' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the top bar. */ + children?: ReactNode; +} & DataAttributeProps; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 32ee4446..615f867a 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -59,6 +59,7 @@ export * from './Sidebar'; export * from './Resizable'; export * from './FileUpload'; export * from './Username'; +export * from './TopBar'; export * from './layout'; export { useListData, diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index f0aede1d..1956fff6 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -57,6 +57,7 @@ "ToastProvider", "Toggle", "Tooltip", + "TopBar", "Tree", "TreeSelect", "Typography", diff --git a/tools/public_api_guard/components/TopBar.api.md b/tools/public_api_guard/components/TopBar.api.md new file mode 100644 index 00000000..eeeacea1 --- /dev/null +++ b/tools/public_api_guard/components/TopBar.api.md @@ -0,0 +1,75 @@ +## 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 { AriaLabelingProps } from '@koobiq/react-core'; +import type { ComponentPropsWithRef } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { ElementType } from 'react'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import type { ReactNode } from 'react'; + +// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts +// +// @public +export const TopBar: CompoundedComponent; + +// @public (undocumented) +export type TopBarBaseProps = { + position?: TopBarPropPosition; + hasShadow?: boolean; + as?: ElementType; + className?: string; + children?: ReactNode; +} & DataAttributeProps; + +// @public +export const TopBarContainer: PolyForwardComponent<"div", TopBarContainerBaseProps, ElementType>; + +// @public (undocumented) +export type TopBarContainerBaseProps = { + placement?: TopBarContainerPropPlacement; + isToolbar?: boolean; + as?: ElementType; + className?: string; + children?: ReactNode; +} & AriaLabelingProps & DataAttributeProps; + +// @public (undocumented) +export type TopBarContainerPropPlacement = (typeof topBarContainerPropPlacement)[number]; + +// @public (undocumented) +export const topBarContainerPropPlacement: readonly ["start", "end"]; + +// @public (undocumented) +export type TopBarContainerProps = ComponentPropsWithRef>; + +// @public (undocumented) +export type TopBarPropPosition = (typeof topBarPropPosition)[number]; + +// @public (undocumented) +export const topBarPropPosition: readonly ["static", "sticky"]; + +// Warning: (ae-forgotten-export) The symbol "TopBarComponent" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type TopBarProps = ComponentPropsWithRef>; + +// @public +export const TopBarTitle: PolyForwardComponent<"h1", TopBarTitleBaseProps, ElementType>; + +// @public (undocumented) +export type TopBarTitleBaseProps = { + as?: ElementType; + className?: string; + children?: ReactNode; +} & DataAttributeProps; + +// @public (undocumented) +export type TopBarTitleProps = ComponentPropsWithRef>; + +// (No @packageDocumentation comment for this package) + +``` From f61245d30ac18436a3b3bf73698dbb1742f3c53f Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 3 Aug 2026 17:55:26 +0300 Subject: [PATCH 2/3] chore(TopBar): finalize responsive layout behavior --- .storybook/components/Roadmap/data.ts | 6 + .../src/components/TopBar/TopBar.mdx | 64 ++++--- .../src/components/TopBar/TopBar.module.css | 49 ++--- .../src/components/TopBar/TopBar.stories.tsx | 169 ++++++++++++------ .../TopBar/__stories__/styles.module.css | 6 - .../TopBarContainer.module.css | 19 +- .../TopBarTitle/TopBarTitle.module.css | 5 +- packages/components/src/global.css | 1 + 8 files changed, 197 insertions(+), 122 deletions(-) delete mode 100644 packages/components/src/components/TopBar/__stories__/styles.module.css diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 1585e632..47c160fe 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -374,4 +374,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q3 2026', }, + { + component: 'TopBar', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q3 2026', + }, ]; diff --git a/packages/components/src/components/TopBar/TopBar.mdx b/packages/components/src/components/TopBar/TopBar.mdx index 449a6d0a..90dc9609 100644 --- a/packages/components/src/components/TopBar/TopBar.mdx +++ b/packages/components/src/components/TopBar/TopBar.mdx @@ -26,12 +26,6 @@ import { TopBar } from '@koobiq/react-components'; -## Props - - - -## Anatomy - The bar holds one container per side. Render only the ones you need. ```tsx @@ -40,7 +34,7 @@ The bar holds one container per side. Render only the ones you need. Dashboards - + @@ -50,7 +44,12 @@ The bar holds one container per side. Render only the ones you need. `placement="end"` sticks to the opposite edge. - `TopBar.Title` — the page heading. It renders an `h1` and cuts long text with an ellipsis. -TopBar keeps at least `--kbq-top-bar-gap` (80px) between the two sides. +By default, TopBar keeps at least `80px` between the two sides. Use `--kbq-top-bar-gap` to override +it. + +## Props + + ## Logo and counter @@ -78,42 +77,39 @@ position yourself and pass the result in. ## Breadcrumbs -Put `Breadcrumbs` in the start container. They collapse on their own — you do not need to add any -logic around them. +Put [Breadcrumbs](?path=/docs/components-breadcrumbs--docs) in the start container. They collapse on +their own — you do not need to add any logic around them. ## Collapsing actions -Collapsing the actions into a "…" menu is not built into TopBar. Build it with `useHideOverflowItems` -and a `Menu`, so you stay in control of which action collapses first. - - - -## Shrinking +Use [useHideOverflowItems](?path=/docs/hooks-usehideoverflowitems--docs) and a `Menu` to collapse +overflowing actions under a "…" button. -The start side gives up its space first and the actions keep their size, so a button label never -turns into an unreadable stub: + -1. The title is cut with an ellipsis down to `--kbq-top-bar-title-min-inline-size`. Breadcrumbs - collapse their middle items, then everything but the last one. -2. Once the start side has nothing left to give, the bar runs out of room. That is the point to - collapse the actions into a "…" menu — see [Collapsing actions](#collapsing-actions). +The example reserves `160px` for the logo and collapsed Breadcrumbs. The `busy` value also includes +the container gap and TopBar inline padding, so Breadcrumbs collapse before the actions. ## CSS variables -| Variable | Default | Purpose | -| ------------------------------------- | ------------------------------------------ | --------------------------------------------------- | -| `--kbq-top-bar-background` | `var(--kbq-background-bg)` | Background of the bar. | -| `--kbq-top-bar-padding-block` | `var(--kbq-size-m)` | Block padding. | -| `--kbq-top-bar-padding-inline` | `var(--kbq-size-xxl)` | Inline padding. | -| `--kbq-top-bar-border-radius` | `0` | Border radius. | -| `--kbq-top-bar-gap` | `80px` | Minimum distance between the two sides. | -| `--kbq-top-bar-z-index` | `calc(var(--kbq-layer-modal) - 1)` | Stack order while `sticky`. | -| `--kbq-top-bar-shadow` | `var(--kbq-shadow-overflow-normal-bottom)` | Shadow shown by `hasShadow`. | -| `--kbq-top-bar-container-start-gap` | `0` | Gap between items in the start container. | -| `--kbq-top-bar-container-end-gap` | `var(--kbq-size-s)` | Gap between items in the end container. | -| `--kbq-top-bar-title-min-inline-size` | `4ch` | Smallest width of the title before the end shrinks. | +These optional variables override the component's internal values. + +| Variable | Purpose | +| ----------------------------------------------- | --------------------------------------------------- | +| `--kbq-top-bar-background` | Background of the bar. | +| `--kbq-top-bar-padding-block` | Block padding. | +| `--kbq-top-bar-padding-inline` | Inline padding. | +| `--kbq-top-bar-border-radius` | Border radius. | +| `--kbq-top-bar-min-block-size` | Minimum block size of the bar. | +| `--kbq-top-bar-gap` | Minimum distance between the two sides. | +| `--kbq-top-bar-z-index` | Stack order while `sticky`. | +| `--kbq-top-bar-shadow` | Shadow shown by `hasShadow`. | +| `--kbq-top-bar-container-start-gap` | Gap between items in the start container. | +| `--kbq-top-bar-container-start-min-inline-size` | Width the start container keeps for itself. | +| `--kbq-top-bar-container-end-gap` | Gap between items in the end container. | +| `--kbq-top-bar-title-min-inline-size` | Smallest width of the title before the end shrinks. | ## Accessibility diff --git a/packages/components/src/components/TopBar/TopBar.module.css b/packages/components/src/components/TopBar/TopBar.module.css index 68d67c32..b77fbf3c 100644 --- a/packages/components/src/components/TopBar/TopBar.module.css +++ b/packages/components/src/components/TopBar/TopBar.module.css @@ -1,40 +1,43 @@ .base { - --kbq-top-bar-background: var(--kbq-background-bg); - --kbq-top-bar-padding-block: var(--kbq-size-m); - --kbq-top-bar-padding-inline: var(--kbq-size-xxl); - --kbq-top-bar-border-radius: 0; - - /* The minimum distance between the start and the end containers. */ - --kbq-top-bar-gap: 80px; - - /* Above the page content, below modals and toasts. */ - --kbq-top-bar-z-index: calc(var(--kbq-layer-modal) - 1); - --kbq-top-bar-shadow: var(--kbq-shadow-overflow-normal-bottom); - --kbq-top-bar-container-start-gap: 0; - --kbq-top-bar-container-end-gap: var(--kbq-size-s); - - /* Roughly three characters plus an ellipsis. */ - --kbq-top-bar-title-min-inline-size: 4ch; + --top-bar-background: var(--kbq-background-bg); + --top-bar-padding-block: var(--kbq-size-m); + --top-bar-padding-inline: var(--kbq-size-xxl); + --top-bar-border-radius: 0; + --top-bar-min-block-size: var(--kbq-size-6xl); + --top-bar-gap: 80px; + --top-bar-z-index: var(--kbq-layer-topbar); + --top-bar-shadow: var(--kbq-shadow-overflow-normal-bottom); + --top-bar-container-start-gap: 0; + --top-bar-container-start-min-inline-size: 0; + --top-bar-container-end-gap: var(--kbq-size-s); + --top-bar-title-min-inline-size: 4ch; display: flex; box-sizing: border-box; align-items: center; - gap: var(--kbq-top-bar-gap); - padding-block: var(--kbq-top-bar-padding-block); - padding-inline: var(--kbq-top-bar-padding-inline); - border-radius: var(--kbq-top-bar-border-radius); - background: var(--kbq-top-bar-background); + min-block-size: var( + --kbq-top-bar-min-block-size, + var(--top-bar-min-block-size) + ); + gap: var(--kbq-top-bar-gap, var(--top-bar-gap)); + padding-block: var(--kbq-top-bar-padding-block, var(--top-bar-padding-block)); + padding-inline: var( + --kbq-top-bar-padding-inline, + var(--top-bar-padding-inline) + ); + border-radius: var(--kbq-top-bar-border-radius, var(--top-bar-border-radius)); + background: var(--kbq-top-bar-background, var(--top-bar-background)); transition: box-shadow var(--kbq-transition-slow); } /* position */ .base[data-position='sticky'] { position: sticky; - z-index: var(--kbq-top-bar-z-index); + z-index: var(--kbq-top-bar-z-index, var(--top-bar-z-index)); inset-block-start: 0; } /* shadow */ .base[data-shadow='true'] { - box-shadow: var(--kbq-top-bar-shadow); + box-shadow: var(--kbq-top-bar-shadow, var(--top-bar-shadow)); } diff --git a/packages/components/src/components/TopBar/TopBar.stories.tsx b/packages/components/src/components/TopBar/TopBar.stories.tsx index f6c66dbd..b6d9c426 100644 --- a/packages/components/src/components/TopBar/TopBar.stories.tsx +++ b/packages/components/src/components/TopBar/TopBar.stories.tsx @@ -1,6 +1,10 @@ -import { type CSSProperties, type SVGProps, useState } from 'react'; +import { type CSSProperties, type SVGProps, useEffect, useState } from 'react'; -import { mergeRefs, useHideOverflowItems } from '@koobiq/react-core'; +import { + mergeProps, + mergeRefs, + useHideOverflowItems, +} from '@koobiq/react-core'; import { IconArrowsRotate16, IconBug16, @@ -20,9 +24,9 @@ import { FlexBox } from '../FlexBox'; import { IconButton } from '../IconButton'; import { spacing } from '../layout'; import { Menu } from '../Menu'; +import { Tooltip } from '../Tooltip'; import { Typography } from '../Typography'; -import s from './__stories__/styles.module.css'; import { TopBar, type TopBarProps, topBarPropPosition } from './index.js'; const meta = { @@ -145,7 +149,7 @@ export const WithLogoAndCounter: Story = { 10 - + @@ -211,7 +215,7 @@ export const Shadow: Story = { Dashboards - + @@ -225,14 +229,21 @@ export const WithBreadcrumbs: Story = { render: (args) => ( - ( + + + + )} > - - + Go to the home page + Main Section @@ -240,21 +251,43 @@ export const WithBreadcrumbs: Story = { Pipeline - - - - - - - + + ( + + + + )} + > + Search + + ( + + + + )} + > + Filter + ), }; -export const CollapsedActions: Story = { +export const CollapsingActions: Story = { render: function Render(args) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const actions = [ { key: 'refresh', label: 'Refresh', icon: }, { key: 'view', label: 'Change view', icon: }, @@ -264,41 +297,67 @@ export const CollapsedActions: Story = { { key: 'report', label: 'Report a bug', icon: }, ]; - /** - * Everything on the bar that the actions may not use: its padding, the gap - * between the two sides, and the room the breadcrumbs keep once they are fully - * collapsed. The actions start moving into the "…" menu below this. - */ - const RESERVED_SPACE = 250; + // Space for the logo and fully collapsed breadcrumbs. + const START_RESERVE = 160; + + // 160px start reserve + 80px gap + 24px padding on each side. + const BUSY_SPACE = 288; + + // Keep hidden actions measurable. + const collapsedStyle: CSSProperties = { + visibility: 'hidden', + position: 'absolute', + insetInlineStart: '-300vw', + }; const moreIndex = actions.length; - // `parentRef` goes on the bar, not on the actions container: the bar keeps - // its width when an action is hidden, so hiding one cannot trigger hiding - // the next. `busy` is the part of that width the actions may not use. + // Measure the bar so hiding an action does not change the available width. + // Use margins because the hook includes them in each action's width. const { parentRef, visibleMap, itemsRefs } = useHideOverflowItems< HTMLButtonElement, HTMLElement >({ length: actions.length + 1, moreIndex, - busy: RESERVED_SPACE, + busy: BUSY_SPACE, }); const collapsedActions = actions.filter((_, index) => !visibleMap[index]); + const isMoreVisible = visibleMap[moreIndex]; + + useEffect(() => { + if (!isMoreVisible) setIsMenuOpen(false); + }, [isMoreVisible]); return ( - + - ( + + + + )} > - - + Go to the home page + Main Section @@ -306,26 +365,36 @@ export const CollapsedActions: Story = { Pipeline - + {actions.map((action, index) => ( - ( + + {action.icon} + + )} > - {action.icon} - + {action.label} + ))} ( @@ -335,7 +404,7 @@ export const CollapsedActions: Story = { placement="bottom end" > {collapsedActions.map((action) => ( - + {action.icon} {action.label} diff --git a/packages/components/src/components/TopBar/__stories__/styles.module.css b/packages/components/src/components/TopBar/__stories__/styles.module.css deleted file mode 100644 index 25c168e7..00000000 --- a/packages/components/src/components/TopBar/__stories__/styles.module.css +++ /dev/null @@ -1,6 +0,0 @@ -/* Keeps a collapsed action measurable while taking it out of the layout. */ -.hiddenAction[aria-hidden='true'] { - visibility: hidden; - position: absolute; - inset-inline-start: -300vw; -} diff --git a/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css index 61ae70f5..9fe22e23 100644 --- a/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css +++ b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css @@ -4,23 +4,26 @@ min-inline-size: 0; } -/* placement */ - -/* The start side gives up all the space: the title is cut and the breadcrumbs - collapse, while the actions keep their size. Once the start side has nothing - left to give, collapse the actions yourself — see the docs. */ .base[data-placement='start'] { flex: 1 1 auto; - gap: var(--kbq-top-bar-container-start-gap); + gap: var( + --kbq-top-bar-container-start-gap, + var(--top-bar-container-start-gap) + ); + min-inline-size: var( + --kbq-top-bar-container-start-min-inline-size, + var(--top-bar-container-start-min-inline-size) + ); + overflow: clip; + overflow-clip-margin: var(--kbq-size-xxs); } .base[data-placement='end'] { flex: 0 0 auto; justify-content: flex-end; - gap: var(--kbq-top-bar-container-end-gap); + gap: var(--kbq-top-bar-container-end-gap, var(--top-bar-container-end-gap)); } -/* Keeps the container at the end edge when there is no start container. */ .base[data-placement='end']:first-child { margin-inline-start: auto; } diff --git a/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css b/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css index d344aece..4e7e8939 100644 --- a/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css +++ b/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css @@ -3,7 +3,10 @@ .base { @mixin ellipsis; - min-inline-size: var(--kbq-top-bar-title-min-inline-size); + min-inline-size: var( + --kbq-top-bar-title-min-inline-size, + var(--top-bar-title-min-inline-size) + ); color: var(--kbq-foreground-contrast); } diff --git a/packages/components/src/global.css b/packages/components/src/global.css index f6f94a25..4c459d1c 100644 --- a/packages/components/src/global.css +++ b/packages/components/src/global.css @@ -2,6 +2,7 @@ /* layer */ --kbq-layer-default: 0; --kbq-layer-absolute: 1; + --kbq-layer-topbar: 950; --kbq-layer-modal: 1000; --kbq-layer-toast: 1100; From 315016efb97c2cab83465eb89715b1888b28e5c6 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 3 Aug 2026 21:05:04 +0300 Subject: [PATCH 3/3] fix(TopBar): resolve public API and data attribute conflicts --- .../src/components/TopBar/TopBar.mdx | 2 +- .../src/components/TopBar/TopBar.test.tsx | 22 +++++++++++++++ .../src/components/TopBar/TopBar.tsx | 17 ++++------- .../TopBarContainer/TopBarContainer.tsx | 2 +- .../public_api_guard/components/TopBar.api.md | 28 +++++++++++++------ 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/components/src/components/TopBar/TopBar.mdx b/packages/components/src/components/TopBar/TopBar.mdx index 90dc9609..4ce21242 100644 --- a/packages/components/src/components/TopBar/TopBar.mdx +++ b/packages/components/src/components/TopBar/TopBar.mdx @@ -70,7 +70,7 @@ The bar always stays in the flow, so the page content never needs a top offset. ## Shadow -The `hasShadow` only paints the bottom shadow. TopBar never listens to scrolling — watch the scroll +The `hasShadow` prop only paints the bottom shadow. TopBar never listens to scrolling — watch the scroll position yourself and pass the result in. diff --git a/packages/components/src/components/TopBar/TopBar.test.tsx b/packages/components/src/components/TopBar/TopBar.test.tsx index a6b2afdd..49490fdb 100644 --- a/packages/components/src/components/TopBar/TopBar.test.tsx +++ b/packages/components/src/components/TopBar/TopBar.test.tsx @@ -88,6 +88,14 @@ describe('TopBar', () => { expect(getRoot()).toHaveAttribute('data-position', position); } ); + + it('should not allow data-position to override position', () => { + render( + + ); + + expect(getRoot()).toHaveAttribute('data-position', 'sticky'); + }); }); describe('check the hasShadow prop', () => { @@ -102,6 +110,12 @@ describe('TopBar', () => { expect(getRoot()).toHaveAttribute('data-shadow', 'true'); }); + + it('should not allow data-shadow to override hasShadow', () => { + render(); + + expect(getRoot()).not.toHaveAttribute('data-shadow'); + }); }); }); @@ -145,6 +159,14 @@ describe('TopBar.Container', () => { } ); + it('should not allow data-placement to override placement', () => { + render( + + ); + + expect(getRoot()).toHaveAttribute('data-placement', 'end'); + }); + describe('check the isToolbar prop', () => { it('should add no toolbar semantics by default', () => { render(); diff --git a/packages/components/src/components/TopBar/TopBar.tsx b/packages/components/src/components/TopBar/TopBar.tsx index 692c3728..9cdbbe64 100644 --- a/packages/components/src/components/TopBar/TopBar.tsx +++ b/packages/components/src/components/TopBar/TopBar.tsx @@ -21,10 +21,10 @@ const TopBarComponent = polymorphicForwardRef<'header', TopBarBaseProps>( return ( {children} @@ -35,19 +35,14 @@ const TopBarComponent = polymorphicForwardRef<'header', TopBarBaseProps>( TopBarComponent.displayName = 'TopBar'; -type CompoundedComponent = typeof TopBarComponent & { - Container: typeof TopBarContainer; - Title: typeof TopBarTitle; -}; - /** * TopBar is the bar at the top of a page. It holds the page title or the * breadcrumbs on the start side and the page actions on the end side. */ -export const TopBar = TopBarComponent as CompoundedComponent; - -TopBar.Container = TopBarContainer; -TopBar.Title = TopBarTitle; +export const TopBar = Object.assign(TopBarComponent, { + Container: TopBarContainer, + Title: TopBarTitle, +}); export type TopBarProps = - ComponentPropsWithRef>; + ComponentPropsWithRef>; diff --git a/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx index 1f2a8fda..5a5bf715 100644 --- a/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx +++ b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.tsx @@ -41,13 +41,13 @@ export const TopBarContainer = polymorphicForwardRef< ); const rootProps = mergeProps( + other, { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'data-placement': placement, className: clsx(s.base, className), }, - other, isToolbar ? toolbarProps : {} ); diff --git a/tools/public_api_guard/components/TopBar.api.md b/tools/public_api_guard/components/TopBar.api.md index eeeacea1..453abf7d 100644 --- a/tools/public_api_guard/components/TopBar.api.md +++ b/tools/public_api_guard/components/TopBar.api.md @@ -6,15 +6,29 @@ import type { AriaLabelingProps } from '@koobiq/react-core'; import type { ComponentPropsWithRef } from 'react'; -import type { DataAttributeProps } from '@koobiq/react-core'; +import { DataAttributeProps } from '@koobiq/react-core'; +import { DetailedHTMLProps } from 'react'; import type { ElementType } from 'react'; +import { ForwardRefExoticComponent } from 'react'; +import { HTMLAttributes } from 'react'; +import { Merge } from '@koobiq/react-core'; import { PolyForwardComponent } from '@koobiq/react-core'; -import type { ReactNode } from 'react'; +import { PolymorphicWithRef } from '@koobiq/react-core'; +import { ReactNode } from 'react'; -// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts -// // @public -export const TopBar: CompoundedComponent; +export const TopBar: Omit, HTMLElement>, { +position?: TopBarPropPosition; +hasShadow?: boolean; +as?: ElementType; +className?: string; +children?: ReactNode; +} & DataAttributeProps & { +as?: "header" | undefined; +}>>, never> & PolymorphicWithRef<"header", TopBarBaseProps, ElementType> & { + Container: PolyForwardComponent<"div", TopBarContainerBaseProps, ElementType>; + Title: PolyForwardComponent<"h1", TopBarTitleBaseProps, ElementType>; +}; // @public (undocumented) export type TopBarBaseProps = { @@ -52,10 +66,8 @@ export type TopBarPropPosition = (typeof topBarPropPosition)[number]; // @public (undocumented) export const topBarPropPosition: readonly ["static", "sticky"]; -// Warning: (ae-forgotten-export) The symbol "TopBarComponent" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export type TopBarProps = ComponentPropsWithRef>; +export type TopBarProps = ComponentPropsWithRef>; // @public export const TopBarTitle: PolyForwardComponent<"h1", TopBarTitleBaseProps, ElementType>;