diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 58f6a3ea..7ab28a62 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -380,4 +380,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 new file mode 100644 index 00000000..4ce21242 --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.mdx @@ -0,0 +1,121 @@ +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 + + + +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. + +By default, TopBar keeps at least `80px` between the two sides. Use `--kbq-top-bar-gap` to override +it. + +## Props + + + +## 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` prop only paints the bottom shadow. TopBar never listens to scrolling β€” watch the scroll +position yourself and pass the result in. + + + +## Breadcrumbs + +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 + +Use [useHideOverflowItems](?path=/docs/hooks-usehideoverflowitems--docs) and a `Menu` to collapse +overflowing actions under a "…" button. + + + +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 + +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 + +- 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..b77fbf3c --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.module.css @@ -0,0 +1,43 @@ +.base { + --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; + 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, var(--top-bar-z-index)); + inset-block-start: 0; +} + +/* shadow */ +.base[data-shadow='true'] { + 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 new file mode 100644 index 00000000..b6d9c426 --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.stories.tsx @@ -0,0 +1,417 @@ +import { type CSSProperties, type SVGProps, useEffect, useState } from 'react'; + +import { + mergeProps, + 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 { Tooltip } from '../Tooltip'; +import { Typography } from '../Typography'; + +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) => ( + + + ( + + + + )} + > + Go to the home page + + + Main + Section + Details + Pipeline + + + + ( + + + + )} + > + Search + + ( + + + + )} + > + Filter + + + + + ), +}; + +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: }, + { 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: }, + ]; + + // 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; + + // 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: 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 + Details + Pipeline + + + + {actions.map((action, index) => ( + ( + + {action.icon} + + )} + > + {action.label} + + ))} + ( + + + + )} + 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..49490fdb --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.test.tsx @@ -0,0 +1,257 @@ +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); + } + ); + + it('should not allow data-position to override position', () => { + render( + + ); + + expect(getRoot()).toHaveAttribute('data-position', 'sticky'); + }); + }); + + 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'); + }); + + it('should not allow data-shadow to override hasShadow', () => { + render(); + + expect(getRoot()).not.toHaveAttribute('data-shadow'); + }); + }); +}); + +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); + } + ); + + 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(); + + 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..9cdbbe64 --- /dev/null +++ b/packages/components/src/components/TopBar/TopBar.tsx @@ -0,0 +1,48 @@ +'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'; + +/** + * 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 = Object.assign(TopBarComponent, { + Container: TopBarContainer, + Title: TopBarTitle, +}); + +export type TopBarProps = + ComponentPropsWithRef>; 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..9fe22e23 --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarContainer/TopBarContainer.module.css @@ -0,0 +1,29 @@ +.base { + display: flex; + align-items: center; + min-inline-size: 0; +} + +.base[data-placement='start'] { + flex: 1 1 auto; + 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, var(--top-bar-container-end-gap)); +} + +.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..5a5bf715 --- /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( + other, + { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'data-placement': placement, + className: clsx(s.base, className), + }, + 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..4e7e8939 --- /dev/null +++ b/packages/components/src/components/TopBar/components/TopBarTitle/TopBarTitle.module.css @@ -0,0 +1,15 @@ +@import url('../../../../styles/mixins.css'); + +.base { + @mixin ellipsis; + + min-inline-size: var( + --kbq-top-bar-title-min-inline-size, + var(--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 438226c6..d6d7948d 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -60,6 +60,7 @@ export * from './Sidebar'; export * from './Resizable'; export * from './FileUpload'; export * from './Username'; +export * from './TopBar'; export * from './layout'; export { useListData, 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; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index ed92420e..c6eefeba 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -58,6 +58,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..453abf7d --- /dev/null +++ b/tools/public_api_guard/components/TopBar.api.md @@ -0,0 +1,87 @@ +## 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 { 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 { PolymorphicWithRef } from '@koobiq/react-core'; +import { ReactNode } from 'react'; + +// @public +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 = { + 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"]; + +// @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) + +```