diff --git a/packages/components/src/components/Tabs/Tabs.mdx b/packages/components/src/components/Tabs/Tabs.mdx index 612e14fee..948e7b6d6 100644 --- a/packages/components/src/components/Tabs/Tabs.mdx +++ b/packages/components/src/components/Tabs/Tabs.mdx @@ -40,6 +40,17 @@ You can render tabs dynamically by using `items` prop. +## Editable + +Editable tabs are controlled from outside. Keep the tab list in your app state, +pass it to the `items` prop, and update it when `onAdd` or `onRemove` is called. + +Tabs can be removed with the close button, Delete or Backspace. + +To see editable tabs in different states, open the [Editable Playground](?path=/story/components-tabs--editable-playground) story. + + + ## Vertical Set the tabs to vertical by passing `orientation="vertical"` to `Tabs`. @@ -104,6 +115,13 @@ You can use the `onSelectionChange` and `selectedKey` props to control the selec +## Keyboard Activation + +Set `keyboardActivation="manual"` to move focus with arrow keys without selecting +the focused tab until the user presses Enter or Space. + + + ## Links Tabs items can be rendered as links by passing the `href` prop to the `Tab` component. diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index 5653ef8e2..78229a3c9 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -1,30 +1,56 @@ -@import url('../../styles/mixins.css'); +.container { + display: flex; + gap: var(--kbq-size-m); +} .base { display: flex; position: relative; } -/* container */ -.container { +.scrollArea { display: flex; - gap: var(--kbq-size-m); + position: relative; + flex: 0 1 auto; + min-inline-size: 0; } -.default { - .tab { - min-inline-size: 40px; +.scrollBox { + --mask-size: 32px; - &.selected .content { - background-color: var(--kbq-states-background-transparent-active); - } + &[data-overflow-inline-start] { + mask-image: linear-gradient( + to right, + transparent var(--mask-size), + #000 calc(var(--mask-size) * 1.5) + ); + } - &.hovered .content { - background-color: var(--kbq-states-background-transparent-hover); - } + &[data-overflow-inline-end] { + mask-image: linear-gradient( + to right, + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); + } + + &[data-overflow-inline-start][data-overflow-inline-end] { + mask-image: linear-gradient( + to right, + transparent var(--mask-size), + #000 calc(var(--mask-size) * 1.5), + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); } } +.tabList { + flex-grow: 1; + display: flex; + position: relative; +} + .underlined { .base::after { content: ''; @@ -33,61 +59,11 @@ position: absolute; block-size: var(--kbq-size-border-width); background: var(--kbq-line-contrast-less); - z-index: var(--kbq-layer-absolute); - } - - .tab { - --tab-content-offset: var(--kbq-size-m); - - min-inline-size: 40px; - box-sizing: content-box; - padding-block: var(--kbq-size-s); - border-inline: var(--tab-content-offset) solid transparent; - - .content { - padding-inline: var(--tab-content-offset); - margin-inline: calc(-1 * var(--tab-content-offset)); - inline-size: calc(100% + calc(2 * var(--tab-content-offset))); - } - - &::after { - content: ''; - position: absolute; - inline-size: 100%; - block-size: 3px; - inset-block-end: 0; - inset-block-start: unset; - border-radius: 2px 2px 0 0; - background-color: transparent; - z-index: calc(var(--kbq-layer-absolute) + 1); - } - } - - .tab.onlyIcon { - --tab-content-offset: 0px; - - min-inline-size: 32px; - border-inline: var(--kbq-size-xs) solid transparent; - } - - .tab:first-child { - border-inline-start: 0; - } - - .tab:last-child { - border-inline-end: 0; - } - - .tab.selected { - &::after { - background-color: var(--kbq-line-contrast); - } + z-index: calc(var(--kbq-layer-absolute) + 3); } - .tab.hovered { - &::after { - background-color: var(--kbq-line-contrast-fade); - } + .tabList { + overflow: clip; } } @@ -104,121 +80,79 @@ .tabList { flex-direction: row; } - - .tab .content { - justify-content: center; - } } .vertical { - .scrollBox { - display: inline-block; - inline-size: auto; - scrollbar-width: auto; - overflow: hidden auto; + .base { + flex-direction: column; + min-block-size: 0; + flex-shrink: 0; } - .tabList { - flex-direction: column; + .scrollArea { + inline-size: 100%; + min-block-size: 0; } - .tab { + .scrollBox { + --mask-size: var(--kbq-size-3xl); + inline-size: 100%; - padding-block: var(--kbq-size-xxs); + display: inline-block; + overflow: hidden auto; + scrollbar-width: auto; + scrollbar-gutter: auto; + scrollbar-color: var(--kbq-scrollbar-thumb-default-background) + var(--kbq-background-transparent); + + &[data-overflow-block-start][data-overflow-block-end] { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--mask-size), + #000 calc(100% - var(--mask-size)), + transparent 100% + ); + } - .content { - justify-content: flex-start; + &[data-overflow-block-start]:not([data-overflow-block-end]) { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--mask-size) + ); } - } - .tab:first-child { - padding-block-start: 0; + &:not([data-overflow-block-start])[data-overflow-block-end] { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--mask-size)), + transparent 100% + ); + } } - .tab:last-child { - padding-block-end: 0; + .tabList { + flex-direction: column; } - .label { - min-inline-size: 0; + [data-slot='add-button'] { + padding-block-start: var(--kbq-size-s); + } - @mixin ellipsis; + &:has(.tabList:empty) { + [data-slot='add-button'] { + padding-block-start: 0; + } } } .stretched { - .scrollBox { - overflow: hidden; - } - - .tab { + .scrollArea { inline-size: 100%; } - .label { - min-inline-size: 0; - - @mixin ellipsis; - } -} - -/* tab-list */ -.tabList { - flex-grow: 1; - display: flex; - position: relative; -} - -/* tab */ -.tab { - display: flex; - outline: none; - cursor: pointer; - position: relative; - align-items: stretch; - text-decoration: none; - box-sizing: border-box; - justify-content: center; - transition: background-color var(--kbq-transition-default); - - &::after { - transition: background-color var(--kbq-transition-default); + .scrollBox { + overflow: hidden; } } - -.focusVisible .content { - outline-color: var(--kbq-states-line-focus-theme); -} - -.disabled .content { - color: var(--kbq-states-foreground-disabled); - cursor: default; -} - -.content { - display: flex; - gap: var(--kbq-size-xxs); - align-items: center; - overflow: hidden; - inline-size: 100%; - white-space: nowrap; - box-sizing: border-box; - min-block-size: 32px; - padding-inline: var(--kbq-size-m); - padding-block: var(--kbq-size-xs); - color: var(--kbq-foreground-contrast); - border-radius: var(--kbq-size-border-radius); - outline-offset: calc(-1 * var(--kbq-size-3xs)); - outline: var(--kbq-size-3xs) solid; - outline-color: transparent; - transition: - outline-color var(--kbq-transition-default), - background-color var(--kbq-transition-default), - color var(--kbq-transition-default); -} - -.addon { - flex-shrink: 0; - display: flex; - align-items: center; -} diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 3f7b2db3b..2d868f4a3 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useBoolean } from '@koobiq/react-core'; +import type { Key } from '@koobiq/react-core'; import { IconApple24, IconBsd24, @@ -14,6 +15,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../Button'; import { FlexBox } from '../FlexBox'; import { Form } from '../Form'; +import { useListData } from '../index'; import { Input } from '../Input'; import { spacing } from '../layout'; import { Link } from '../Link'; @@ -29,7 +31,7 @@ const meta = { parameters: { layout: 'padded', }, - tags: ['status:updated', 'date:2026-04-30'], + tags: ['status:updated', 'date:2026-06-29'], } satisfies Meta; export default meta; @@ -96,6 +98,162 @@ export const Dynamic: Story = { }, }; +export const Editable: Story = { + render: function Render() { + const content = + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Alias delectus fugit maxime nulla odio sunt ullam vitae! A aut beatae consequatur deserunt dicta dignissimos distinctio doloribus eos expedita fuga, hic illo ipsam ipsum iste, laboriosam nam neque nesciunt odio optio possimus, praesentium quasi quia quisquam sequi voluptatum. Amet, architecto, nisi.'; + + const list = useListData({ + initialItems: [ + { id: 'bruteforce', title: 'Bruteforce', content }, + { id: 'complex-attack', title: 'Complex Attack', content }, + { id: 'ddos', title: 'DDoS', content }, + { id: 'dos', title: 'DoS', content }, + { id: 'hips', title: 'HIPS Alert', content }, + { id: 'identity-theft', title: 'Identity Theft', content }, + { id: 'ids-ips', title: 'IDS/IPS Alert', content }, + { id: 'misc', title: 'Miscellaneous', content }, + ], + }); + + const [selectedKey, setSelectedKey] = useState( + list.items[0]?.id + ); + + const counter = useRef(0); + + const removeTabs = (keys: Set) => { + if (selectedKey != null && keys.has(selectedKey)) { + const idx = list.items.findIndex((item) => item.id === selectedKey); + const next = list.items[idx + 1] ?? list.items[idx - 1]; + + setSelectedKey(next?.id); + } + + list.remove(...keys); + }; + + const addTab = () => { + counter.current += 1; + const id = `new-${counter.current}`; + + list.append({ id, title: `New tab ${counter.current}`, content }); + setSelectedKey(id); + }; + + return ( + + {(item) => ( + } key={item.id} title={item.title}> + {item.content} + + )} + + ); + }, +}; + +export const EditablePlayground: Story = { + render: function Render() { + const [isVertical, { set: setVertical }] = useBoolean(false); + const [isUnderlined, { set: setUnderlined }] = useBoolean(false); + const [isStretched, { set: setStretched }] = useBoolean(false); + const [isDisabled, { set: setDisabled }] = useBoolean(false); + + const list = useListData({ + initialItems: [ + { id: 'bruteforce', title: 'Bruteforce' }, + { id: 'complex-attack', title: 'Complex Attack' }, + { id: 'ddos', title: 'DDoS' }, + { id: 'dos', title: 'DoS' }, + { id: 'hips', title: 'HIPS Alert' }, + { id: 'identity-theft', title: 'Identity Theft' }, + { id: 'ids-ips', title: 'IDS/IPS Alert' }, + { id: 'misc', title: 'Miscellaneous' }, + ], + }); + + const [selectedKey, setSelectedKey] = useState( + list.items[0]?.id + ); + + const counter = useRef(0); + + const removeTabs = (keys: Set) => { + if (selectedKey != null && keys.has(selectedKey)) { + const idx = list.items.findIndex((item) => item.id === selectedKey); + const next = list.items[idx + 1] ?? list.items[idx - 1]; + + setSelectedKey(next?.id); + } + + list.remove(...keys); + }; + + const addTab = () => { + counter.current += 1; + const id = `new-${counter.current}`; + + list.append({ id, title: `New tab ${counter.current}` }); + setSelectedKey(id); + }; + + return ( + + + + Vertical + + + Underlined + + + Stretched + + + Disabled + + + + {(item) => ( + } key={item.id} title={item.title}> + {item.title} content + + )} + + + ); + }, +}; + export const WithIcons: Story = { render: function Render(args) { const [isVertical, { set }] = useBoolean(false); @@ -359,6 +517,32 @@ export const Controlled: Story = { }, }; +export const KeyboardActivation: Story = { + render: function Render(args) { + return ( + + + A brute-force attack systematically guesses passwords or cryptographic + keys, often using automated tools to try vast combinations until + access is gained. + + + A denial-of-service attack floods a server or exploits resource-heavy + operations to exhaust CPU, memory, bandwidth, or connection limits. + + + Distributed Denial of Service (DDoS) uses a botnet of infected devices + to send massive, coordinated traffic to a victim. + + + ); + }, +}; + export const Links: Story = { render: function Render(args) { return ( @@ -408,7 +592,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Email" name="email" - placeholder="Enter your email" type="email" autoComplete="email" /> @@ -416,7 +599,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Password" name="password" - placeholder="Enter your password" type="password" autoComplete="current-password" /> @@ -435,7 +617,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Name" name="name" - placeholder="Enter your name" type="text" autoComplete="name" /> @@ -443,7 +624,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Email" name="email" - placeholder="Enter your email" type="email" autoComplete="email" /> @@ -451,7 +631,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Password" name="password" - placeholder="Enter your password" type="password" autoComplete="new-password" /> diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index 58cb9ea4b..1d17e33ac 100644 --- a/packages/components/src/components/Tabs/Tabs.test.tsx +++ b/packages/components/src/components/Tabs/Tabs.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -190,69 +190,129 @@ describe('Tabs', () => { }); it('should render the next button when tab-list > scroll-box', () => { - const { container, rerender } = render(renderComponent({})); + const { container } = render(renderComponent({})); - const tabsList = container.querySelector(`.${s.tabList}`); const scrollBox = container.querySelector(`.${s.scrollBox}`); - Object.defineProperty(tabsList, 'clientWidth', { value: 300 }); Object.defineProperty(scrollBox, 'clientWidth', { value: 200 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); - rerender(renderComponent({})); + if (scrollBox) fireEvent.scroll(scrollBox); expect(hasNextScrollButton(container)).toBe(true); }); it('should NOT render the next button when tab-list <= scroll-box', () => { - const { container, rerender } = render(renderComponent({})); + const { container } = render(renderComponent({})); - const tabsList = container.querySelector(`.${s.tabList}`); const scrollBox = container.querySelector(`.${s.scrollBox}`); - Object.defineProperty(tabsList, 'clientWidth', { + Object.defineProperty(scrollBox, 'clientWidth', { value: 200, configurable: true, }); - Object.defineProperty(scrollBox, 'clientWidth', { + Object.defineProperty(scrollBox, 'scrollWidth', { value: 200, configurable: true, }); - rerender(renderComponent({})); + if (scrollBox) fireEvent.scroll(scrollBox); + expect(hasNextScrollButton(container)).toBe(false); - Object.defineProperty(tabsList, 'clientWidth', { value: 199 }); - Object.defineProperty(scrollBox, 'clientWidth', { value: 200 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 199 }); + + if (scrollBox) fireEvent.scroll(scrollBox); - rerender(renderComponent({})); expect(hasNextScrollButton(container)).toBe(false); }); it('should render next/prev buttons when content overflows and already scrolled', () => { - const { container, rerender } = render(renderComponent({})); + const { container } = render(renderComponent({})); - const tabsList = container.querySelector(`.${s.tabList}`); const scrollBox = container.querySelector(`.${s.scrollBox}`); - Object.defineProperty(tabsList, 'clientWidth', { value: 300 }); Object.defineProperty(scrollBox, 'clientWidth', { value: 200 }); - Object.defineProperty(scrollBox, 'scrollLeft', { value: 10 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); - rerender(renderComponent({})); + Object.defineProperty(scrollBox, 'scrollLeft', { + value: 10, + configurable: true, + writable: true, + }); + + if (scrollBox) fireEvent.scroll(scrollBox); expect(hasPrevScrollButton(container)).toBe(true); expect(hasNextScrollButton(container)).toBe(true); }); + it('should keep overflow attributes in sync when orientation changes', () => { + const { container, rerender } = render(renderComponent({})); + + let root = container.firstElementChild; + let scrollBox = container.querySelector(`.${s.scrollBox}`); + + Object.defineProperty(scrollBox, 'clientWidth', { + value: 200, + configurable: true, + }); + + Object.defineProperty(scrollBox, 'scrollWidth', { + value: 300, + configurable: true, + }); + + if (scrollBox) fireEvent.scroll(scrollBox); + + expect(root).toHaveAttribute('data-horizontal-scrollable', 'true'); + expect(root).not.toHaveAttribute('data-vertical-scrollable'); + expect(scrollBox).toHaveAttribute('data-overflow-inline-end', 'true'); + expect(scrollBox).not.toHaveAttribute('data-overflow-block-end'); + + rerender(renderComponent({ orientation: 'vertical' })); + + root = container.firstElementChild; + scrollBox = container.querySelector(`.${s.scrollBox}`); + + expect(root).not.toHaveAttribute('data-horizontal-scrollable'); + expect(scrollBox).not.toHaveAttribute('data-overflow-inline-end'); + + Object.defineProperty(scrollBox, 'clientHeight', { + value: 200, + configurable: true, + }); + + Object.defineProperty(scrollBox, 'scrollHeight', { + value: 300, + configurable: true, + }); + + if (scrollBox) fireEvent.scroll(scrollBox); + + expect(root).toHaveAttribute('data-vertical-scrollable', 'true'); + expect(root).not.toHaveAttribute('data-horizontal-scrollable'); + expect(scrollBox).toHaveAttribute('data-overflow-block-end', 'true'); + expect(scrollBox).not.toHaveAttribute('data-overflow-inline-end'); + + rerender(renderComponent({})); + + root = container.firstElementChild; + scrollBox = container.querySelector(`.${s.scrollBox}`); + + expect(root).not.toHaveAttribute('data-vertical-scrollable'); + expect(scrollBox).not.toHaveAttribute('data-overflow-block-end'); + }); + it('should scroll vertically when selected tab is out of view', async () => { vi.useFakeTimers(); try { - const { container } = render( + const { container, rerender } = render( renderComponent({ orientation: 'vertical', - selectedKey: '4', + selectedKey: '1', }) ); @@ -260,14 +320,88 @@ describe('Tabs', () => { `.${s.scrollBox}` ) as HTMLElement; + Object.defineProperty(scrollBox, 'scrollTop', { + value: 0, + configurable: true, + writable: true, + }); + + Object.defineProperty(scrollBox, 'clientHeight', { + value: 100, + configurable: true, + }); + + Object.defineProperty(scrollBox, 'scrollHeight', { + value: 200, + configurable: true, + }); + + vi.spyOn(scrollBox, 'getBoundingClientRect').mockReturnValue({ + top: 0, + bottom: 100, + left: 0, + right: 200, + } as DOMRect); + + fireEvent.scroll(scrollBox); + + rerender( + renderComponent({ + orientation: 'vertical', + selectedKey: '4', + }) + ); + const selectedTab = screen.getByRole('tab', { selected: true }); + vi.spyOn(selectedTab, 'getBoundingClientRect').mockReturnValue({ + top: 120, + bottom: 160, + left: 0, + right: 200, + } as DOMRect); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + expect(scrollBox.scrollTop).toBe(60); + } finally { + vi.useRealTimers(); + } + }); + + it('should not correct scroll when there is no overflow', async () => { + vi.useFakeTimers(); + + try { + const { container, rerender } = render( + renderComponent({ + orientation: 'vertical', + selectedKey: '1', + }) + ); + + const scrollBox = container.querySelector( + `.${s.scrollBox}` + ) as HTMLElement; + Object.defineProperty(scrollBox, 'scrollTop', { value: 0, configurable: true, writable: true, }); + Object.defineProperty(scrollBox, 'clientHeight', { + value: 100, + configurable: true, + }); + + Object.defineProperty(scrollBox, 'scrollHeight', { + value: 100, + configurable: true, + }); + vi.spyOn(scrollBox, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 100, @@ -275,6 +409,15 @@ describe('Tabs', () => { right: 200, } as DOMRect); + rerender( + renderComponent({ + orientation: 'vertical', + selectedKey: '4', + }) + ); + + const selectedTab = screen.getByRole('tab', { selected: true }); + vi.spyOn(selectedTab, 'getBoundingClientRect').mockReturnValue({ top: 120, bottom: 160, @@ -286,7 +429,7 @@ describe('Tabs', () => { await vi.advanceTimersByTimeAsync(100); }); - expect(scrollBox.scrollTop).toBe(60); + expect(scrollBox.scrollTop).toBe(0); } finally { vi.useRealTimers(); } @@ -294,21 +437,24 @@ describe('Tabs', () => { describe('scroll buttons behavior', () => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { - cb(0); + const id = window.setTimeout(() => cb(performance.now()), 0); + + return id; + }); - return 0; + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + window.clearTimeout(id); }); vi.spyOn(global.Math, 'min').mockReturnValue(1); it('should scroll to right when clicking the next button', () => { - const { container, rerender } = render(renderComponent({})); + const { container } = render(renderComponent({})); - const tabsList = container.querySelector(`.${s.tabList}`); const scrollBox = container.querySelector(`.${s.scrollBox}`); - Object.defineProperty(tabsList, 'clientWidth', { value: 300 }); Object.defineProperty(scrollBox, 'clientWidth', { value: 200 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); Object.defineProperty(scrollBox, 'scrollLeft', { value: 0, @@ -316,7 +462,7 @@ describe('Tabs', () => { writable: true, }); - rerender(renderComponent({})); + if (scrollBox) fireEvent.scroll(scrollBox); const nextScrollButton = findScrollButton(container, ariaLabelNextBtn); @@ -327,13 +473,12 @@ describe('Tabs', () => { }); it('should scroll to left when clicking the prev button', () => { - const { container, rerender } = render(renderComponent({})); + const { container } = render(renderComponent({})); - const tabsList = container.querySelector(`.${s.tabList}`); const scrollBox = container.querySelector(`.${s.scrollBox}`); - Object.defineProperty(tabsList, 'clientWidth', { value: 300 }); Object.defineProperty(scrollBox, 'clientWidth', { value: 200 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); Object.defineProperty(scrollBox, 'scrollLeft', { value: 100, @@ -341,7 +486,7 @@ describe('Tabs', () => { writable: true, }); - rerender(renderComponent({})); + if (scrollBox) fireEvent.scroll(scrollBox); const prevScrollButton = findScrollButton(container, ariaLabelPrevBtn); @@ -351,3 +496,102 @@ describe('Tabs', () => { }); }); }); + +describe('Tabs editable', () => { + const removeLabel = 'Remove tab'; + const addLabel = 'Add tab'; + + it('does not render close buttons without onRemove', () => { + render(renderComponent({})); + + expect( + screen.queryByRole('button', { name: removeLabel }) + ).not.toBeInTheDocument(); + }); + + it('renders a close button on every tab when onRemove is provided', () => { + render(renderComponent({ onRemove: vi.fn() })); + + expect(screen.getAllByRole('button', { name: removeLabel })).toHaveLength( + 4 + ); + }); + + it('calls onRemove with the tab key when its close button is pressed', async () => { + const onRemove = vi.fn(); + + render(renderComponent({ onRemove })); + + const tab = screen.getByTestId(TAB__TEST_ID); + const closeButton = within(tab).getByRole('button', { name: removeLabel }); + + await userEvent.click(closeButton); + + expect(onRemove).toHaveBeenCalledTimes(1); + + const keys = onRemove.mock.calls[0][0]; + + expect(keys).toBeInstanceOf(Set); + expect([...keys]).toEqual(['2']); + }); + + it('does not change selection when a close button is pressed', async () => { + const onSelectionChange = vi.fn(); + const onRemove = vi.fn(); + + render(renderComponent({ onRemove, onSelectionChange, selectedKey: 1 })); + + const tab = screen.getByTestId(TAB__TEST_ID); + const closeButton = within(tab).getByRole('button', { name: removeLabel }); + + await userEvent.click(closeButton); + + expect(onRemove).toHaveBeenCalledTimes(1); + // pressing × must not activate the tab it belongs to + expect(onSelectionChange).not.toHaveBeenCalledWith('2'); + }); + + it('removes the focused tab on Delete', async () => { + const onRemove = vi.fn(); + + render(renderComponent({ onRemove })); + + act(() => screen.getByRole('tab', { name: 'tab-2' }).focus()); + await userEvent.keyboard('{Delete}'); + + expect(onRemove).toHaveBeenCalledTimes(1); + expect([...onRemove.mock.calls[0][0]]).toEqual(['2']); + }); + + it('does not render the add button without onAdd', () => { + render(renderComponent({})); + + expect( + screen.queryByRole('button', { name: addLabel }) + ).not.toBeInTheDocument(); + }); + + it('calls onAdd when the add button is pressed', async () => { + const onAdd = vi.fn(); + + render(renderComponent({ onAdd })); + + await userEvent.click(screen.getByRole('button', { name: addLabel })); + + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('does not call onAdd when tabs are disabled', async () => { + const onAdd = vi.fn(); + + render(renderComponent({ isDisabled: true, onAdd })); + + const addButton = screen.getByRole('button', { name: addLabel }); + + expect(addButton).toBeDisabled(); + + await userEvent.click(addButton); + + expect(onAdd).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index f32f93450..5517a86f0 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -17,7 +17,12 @@ import { useTabList, useTabListState } from '@koobiq/react-primitives'; import { utilClasses } from '../../styles/utility'; -import { Tab as TabInner, TabPanel, TabScrollButton } from './components'; +import { + Tab as TabInner, + TabAddButton, + TabPanel, + TabScrollButton, +} from './components'; import intlMessages from './intl.json'; import type { TabProps as TabItemProps } from './Tab'; import s from './Tabs.module.css'; @@ -35,6 +40,9 @@ export function TabsRender( 'data-testid': dataTestId, isUnderlined = false, isStretched: isStretchedProp = false, + isDisabled = false, + onRemove, + onAdd, style, className, slotProps, @@ -47,14 +55,25 @@ export function TabsRender( const [isMounted, setIsMounted] = useState(false); - const [scrollButtonsVisibility, setScrollButtonsVisibility] = useState({ - prev: false, - next: false, + const [horizontalOverflow, setHorizontalOverflow] = useState({ + start: false, + end: false, + }); + + const [verticalOverflow, setVerticalOverflow] = useState({ + start: false, + end: false, }); const orientation = isUnderlined ? 'horizontal' : orientationProp; const isHorizontal = orientation === 'horizontal'; const isStretched = isHorizontal && isStretchedProp; + const isRemovable = isNotNil(onRemove); + const hasAddButton = isNotNil(onAdd); + + const isAddButtonDisabled = + isDisabled || Boolean(slotProps?.addButton?.isDisabled); + const selectedItemIdx = selectedItem?.index; const selectedTabProps = selectedItem?.props as TabItemProps | undefined; @@ -95,49 +114,59 @@ export function TabsRender( } }; - /** Syncs prev/next button visibility with current scroll position. */ - const updateScrollButtonsVisibility = () => { + /** Syncs the horizontal fade mask with the current scroll position. */ + const updateHorizontalOverflow = () => { if (!isHorizontal) return; - if (!isNotNil(selectedItemIdx)) return; + const el = scrollBoxRef.current; + if (!el) return; - if (!itemsRefs[selectedItemIdx]) return; + const start = el.scrollLeft > 0; + const end = Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth; - const { scrollBoxMeta, tabsListMeta } = getTabsMeta( - tabListRef, - scrollBoxRef, - itemsRefs[selectedItemIdx] + setHorizontalOverflow((prevState) => + prevState.start === start && prevState.end === end + ? prevState + : { start, end } ); + }; - if (!scrollBoxMeta || !tabsListMeta) return; - - const { scrollLeft } = scrollBoxMeta; + /** Syncs the vertical fade mask with the current scroll position. */ + const updateVerticalOverflow = () => { + if (isHorizontal) return; - const isPrevButtonActive = scrollLeft > 0; - const isNextButtonActive = tabsListMeta.right - scrollBoxMeta.right > 1; + const el = scrollBoxRef.current; + if (!el) return; - setScrollButtonsVisibility((prevState) => { - const { prev, next } = prevState; + const start = el.scrollTop > 0; + const end = Math.ceil(el.scrollTop + el.clientHeight) < el.scrollHeight; - if (isPrevButtonActive !== prev || isNextButtonActive !== next) { - return { prev: isPrevButtonActive, next: isNextButtonActive }; - } + setVerticalOverflow((prevState) => + prevState.start === start && prevState.end === end + ? prevState + : { start, end } + ); + }; - return prevState; - }); + /** Syncs horizontal/vertical overflow affordances with the current scroll position. */ + const updateScrollState = () => { + updateHorizontalOverflow(); + updateVerticalOverflow(); }; - const hasHScroll = - isHorizontal && - tabListRef.current && - scrollBoxRef.current && - tabListRef.current?.clientWidth > scrollBoxRef.current?.clientWidth; + const hasHorizontalOverflow = + isHorizontal && (horizontalOverflow.start || horizontalOverflow.end); + + const hasVerticalOverflow = + !isHorizontal && (verticalOverflow.start || verticalOverflow.end); - const hasVScroll = - !isHorizontal && - tabListRef.current && - scrollBoxRef.current && - tabListRef.current.clientHeight > scrollBoxRef.current.clientHeight; + const activeHorizontalOverflow = isHorizontal + ? horizontalOverflow + : { start: false, end: false }; + + const activeVerticalOverflow = isHorizontal + ? { start: false, end: false } + : verticalOverflow; /** Adjusts the scroll position based on the selected tab's position and orientation. */ const scrollCorrection = ( @@ -145,6 +174,8 @@ export function TabsRender( itemIdx = selectedItemIdx, behavior: ScrollBehavior = 'smooth' ) => { + if (!hasHorizontalOverflow && !hasVerticalOverflow) return; + if (!isNotNil(itemIdx)) return; const selectedEl = itemsRefs[itemIdx]; @@ -191,8 +222,8 @@ export function TabsRender( } }; - const [debouncedUpdateScrollButtonsActivity] = useDebounceCallback({ - callback: updateScrollButtonsVisibility, + const [debouncedUpdateScrollState] = useDebounceCallback({ + callback: updateScrollState, delay: 100, }); @@ -203,7 +234,7 @@ export function TabsRender( useResizeObserverRefs( useMemo(() => [scrollBoxRef], [scrollBoxRef]), - () => debouncedUpdateScrollButtonsActivity() + () => debouncedUpdateScrollState() ); const scrollPrev = () => { @@ -225,13 +256,16 @@ export function TabsRender( useEffect(() => { if (!isNotNil(selectedItemIdx)) return; - if (isMounted) { - debouncedScrollCorrection(orientation); - } else { - updateScrollButtonsVisibility(); + updateScrollState(); + + if (!isMounted) { setIsMounted(true); + + return; } - }, [selectedItemIdx, isMounted]); + + debouncedScrollCorrection(orientation); + }, [selectedItemIdx, isMounted, orientation]); const tabsProps = mergeProps({ className: s.base }, slotProps?.tabs); @@ -248,7 +282,11 @@ export function TabsRender( { ref: scrollBoxRef, className: s.scrollBox, - onScroll: updateScrollButtonsVisibility, + onScroll: updateScrollState, + 'data-overflow-inline-start': activeHorizontalOverflow.start || undefined, + 'data-overflow-inline-end': activeHorizontalOverflow.end || undefined, + 'data-overflow-block-start': activeVerticalOverflow.start || undefined, + 'data-overflow-block-end': activeVerticalOverflow.end || undefined, }, slotProps?.scrollBox ); @@ -259,13 +297,13 @@ export function TabsRender( style={style} data-testid={dataTestId} data-orientation={orientation} + data-editable={isRemovable || hasAddButton || undefined} data-stretched={isStretched || undefined} data-underlined={isUnderlined || undefined} - data-vertical-scrollable={hasVScroll || undefined} - data-horizontal-scrollable={hasHScroll || undefined} + data-vertical-scrollable={hasVerticalOverflow || undefined} + data-horizontal-scrollable={hasHorizontalOverflow || undefined} className={clsx( s.container, - !isUnderlined && s.default, isStretched && s.stretched, isUnderlined && s.underlined, orientation && s[orientation], @@ -273,35 +311,55 @@ export function TabsRender( )} >
- {hasHScroll && ( - <> - - - - )} -
-
- {[...state.collection].map((item, i) => ( - scrollCorrection(orientation, i, 'auto')} +
+ {hasHorizontalOverflow && ( + <> + + - ))} + + )} +
+
+ {[...state.collection].map((item, i) => ( + onRemove?.(new Set([item.key])), + })} + onFocused={() => scrollCorrection(orientation, i, 'auto')} + /> + ))} +
+ {hasAddButton && ( + onAdd?.()} + {...slotProps?.addButton} + orientation={orientation} + isUnderlined={isUnderlined} + isDisabled={isAddButtonDisabled} + /> + )}
{hasSelectedTabPanel && ( = { item: Node; state: TabListState; innerRef: Ref; + orientation: TabOrientation; + isUnderlined?: boolean; + isStretched?: boolean; onFocused?: () => void; + onRemove?: () => void; + closeButtonProps?: IconButtonProps; }; -export function Tab({ item, state, innerRef, onFocused }: TabProps) { +export function Tab({ + item, + state, + innerRef, + orientation, + isUnderlined = false, + isStretched = false, + onFocused, + onRemove, + closeButtonProps, +}: TabProps) { const { key, rendered } = item; + const t = useLocalizedStringFormatter(intlMessages); + + const allowsRemoving = !!onRemove; const domRef = useDOMRef(innerRef); const { tabProps, isSelected, isDisabled } = useTab({ key }, state, domRef); @@ -38,7 +67,7 @@ export function Tab({ item, state, innerRef, onFocused }: TabProps) { Boolean(onlyIconProp) && (isNotNil(startAddon) || isNotNil(endAddon)); const { hoverProps, isHovered } = useHover({ - isDisabled: isDisabled || isSelected, + isDisabled: isDisabled, }); const { isFocusVisible, focusProps } = useFocusRing(); @@ -53,36 +82,74 @@ export function Tab({ item, state, innerRef, onFocused }: TabProps) { const endAddonProps = mergeProps({ className: s.addon }, slotProps?.endAddon); + const { className: closeButtonClassName, ...closeButtonRestProps } = + closeButtonProps ?? {}; + const Tag: ElementType = href ? 'a' : 'div'; + const onKeyDown = (event: KeyboardEvent) => { + if (!allowsRemoving || isDisabled) return; + + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + onRemove?.(); + } + }; + return (
- {isNotNil(startAddon) &&
{startAddon}
} - {!onlyIcon && isNotNil(rendered) && ( -
{rendered}
- )} - {isNotNil(endAddon) &&
{endAddon}
} + + {isNotNil(startAddon) &&
{startAddon}
} + {!onlyIcon && isNotNil(rendered) && ( +
{rendered}
+ )} + {isNotNil(endAddon) &&
{endAddon}
} +
+ {allowsRemoving && ( + onRemove?.()} + className={clsx(s.closeButton, closeButtonClassName)} + isCompact + preventFocusOnPress + {...closeButtonRestProps} + > + + + )}
); } diff --git a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css new file mode 100644 index 000000000..8e78065b2 --- /dev/null +++ b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css @@ -0,0 +1,60 @@ +.base { + display: flex; + margin: 0; + padding: 0; + border: 0; + outline: none; + cursor: pointer; + position: relative; + flex-shrink: 0; + appearance: none; + font: inherit; + color: inherit; + background: none; + + &[data-hovered] .content { + background-color: var(--kbq-states-background-transparent-hover); + } + + &[data-pressed] .content { + background-color: var(--kbq-states-background-transparent-active); + } + + &[data-focus-visible] .content { + outline-color: var(--kbq-states-line-focus-theme); + } + + &[data-disabled] .content { + color: var(--kbq-states-foreground-disabled); + cursor: default; + } +} + +.underlined { + padding-block: var(--kbq-size-s); +} + +.vertical { + inline-size: 100%; + + .content { + background-color: var(--kbq-states-background-transparent-active); + } +} + +.content { + display: flex; + align-items: center; + justify-content: center; + inline-size: 100%; + box-sizing: border-box; + min-block-size: 32px; + padding-inline: var(--kbq-size-s); + padding-block: var(--kbq-size-xs); + color: var(--kbq-foreground-contrast); + border-radius: var(--kbq-size-border-radius); + outline-offset: calc(-1 * var(--kbq-size-3xs)); + outline: var(--kbq-size-3xs) solid; + outline-color: transparent; + transition: outline-color var(--kbq-transition-default); +} diff --git a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx new file mode 100644 index 000000000..5aa51d472 --- /dev/null +++ b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx @@ -0,0 +1,57 @@ +import { type ComponentRef, forwardRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; +import { IconPlus16 } from '@koobiq/react-icons'; +import { + Button as ButtonPrimitive, + type ButtonProps, +} from '@koobiq/react-primitives'; + +import s from './TabAddButton.module.css'; + +type TabAddButtonOrientation = 'horizontal' | 'vertical'; + +export type TabAddButtonProps = Omit & { + className?: string; +}; + +type TabAddButtonViewProps = TabAddButtonProps & { + orientation?: TabAddButtonOrientation; + isUnderlined?: boolean; +}; + +/** + * The trailing add ("+") button for editable tabs. Built on the primitive + * Button and styled with the tab surface states, so it matches tabs in every + * state. + */ +export const TabAddButton = forwardRef< + ComponentRef<'button'>, + TabAddButtonViewProps +>( + ( + { className, orientation = 'horizontal', isUnderlined = false, ...other }, + ref + ) => ( + + + + + + ) +); + +TabAddButton.displayName = 'TabAddButton'; diff --git a/packages/components/src/components/Tabs/components/TabAddButton/index.ts b/packages/components/src/components/Tabs/components/TabAddButton/index.ts new file mode 100644 index 000000000..c55a802be --- /dev/null +++ b/packages/components/src/components/Tabs/components/TabAddButton/index.ts @@ -0,0 +1 @@ +export * from './TabAddButton'; diff --git a/packages/components/src/components/Tabs/components/TabScrollButton/TabScrollButton.module.css b/packages/components/src/components/Tabs/components/TabScrollButton/TabScrollButton.module.css index aa3217dd7..70e8e8fb3 100644 --- a/packages/components/src/components/Tabs/components/TabScrollButton/TabScrollButton.module.css +++ b/packages/components/src/components/Tabs/components/TabScrollButton/TabScrollButton.module.css @@ -1,50 +1,18 @@ .base[data-slot='scroll-button'] { - --tabs-scroll-button-inline-size: var(--kbq-size-3xl); + --icon-button-size: 32px; opacity: 1; flex-shrink: 0; block-size: 100%; position: absolute; - inline-size: var(--tabs-scroll-button-inline-size); z-index: calc(var(--kbq-layer-absolute) + 2); - &::after { - content: ''; - z-index: -1; - block-size: 100%; - position: absolute; - pointer-events: none; - inline-size: calc(1.75 * var(--tabs-scroll-button-inline-size)); - } - &.prev { inset-inline-start: 0; - - &::after { - inset-block-start: 0; - inset-inline-start: 0; - background: linear-gradient( - to right, - var(--kbq-background-bg) 0, - var(--kbq-background-bg) var(--tabs-scroll-button-inline-size), - var(--kbq-background-transparent) 100% - ); - } } &.next { inset-inline-end: 0; - - &::after { - inset-block-start: 0; - inset-inline-end: 0; - background: linear-gradient( - to left, - var(--kbq-background-bg) 0, - var(--kbq-background-bg) var(--tabs-scroll-button-inline-size), - var(--kbq-background-transparent) 100% - ); - } } &.invisible { diff --git a/packages/components/src/components/Tabs/components/index.ts b/packages/components/src/components/Tabs/components/index.ts index 77f74d56d..a0e620551 100644 --- a/packages/components/src/components/Tabs/components/index.ts +++ b/packages/components/src/components/Tabs/components/index.ts @@ -1,3 +1,4 @@ export * from './Tab'; +export * from './TabAddButton'; export * from './TabPanel'; export * from './TabScrollButton'; diff --git a/packages/components/src/components/Tabs/intl.json b/packages/components/src/components/Tabs/intl.json index 1fbffb0f7..6005fe588 100644 --- a/packages/components/src/components/Tabs/intl.json +++ b/packages/components/src/components/Tabs/intl.json @@ -1,10 +1,14 @@ { "ru-RU": { "next": "Следующие вкладки", - "prev": "Предыдущие вкладки" + "prev": "Предыдущие вкладки", + "add": "Добавить вкладку", + "remove": "Удалить вкладку" }, "en-US": { "next": "Next tabs", - "prev": "Previous tabs" + "prev": "Previous tabs", + "add": "Add tab", + "remove": "Remove tab" } } diff --git a/packages/components/src/components/Tabs/types.ts b/packages/components/src/components/Tabs/types.ts index 5c56d02f0..732724bda 100644 --- a/packages/components/src/components/Tabs/types.ts +++ b/packages/components/src/components/Tabs/types.ts @@ -6,8 +6,13 @@ import type { Ref, } from 'react'; +import type { Key } from '@koobiq/react-core'; import type { AriaTabListProps } from '@koobiq/react-primitives'; +import type { IconButtonProps } from '../IconButton'; + +import type { TabAddButtonProps } from './components'; + export type TabsProps = AriaTabListProps & { /** Ref to the tabs. */ ref?: Ref; @@ -20,12 +25,18 @@ export type TabsProps = AriaTabListProps & { * @default false */ isStretched?: boolean; + /** Handler that is called when a user deletes a tab. */ + onRemove?: (keys: Set) => void; + /** Handler that is called when the add button is pressed. */ + onAdd?: () => void; /** The props used for each slot inside. */ slotProps?: { tabs?: ComponentProps<'div'>; tabList?: ComponentProps<'div'>; tabPanel?: ComponentProps<'div'>; scrollBox?: ComponentProps<'div'>; + addButton?: TabAddButtonProps; + closeButton?: IconButtonProps; /** @deprecated */ indicator?: ComponentProps<'span'>; }; diff --git a/tools/public_api_guard/components/Tabs.api.md b/tools/public_api_guard/components/Tabs.api.md index 85bb88f21..2e45093c7 100644 --- a/tools/public_api_guard/components/Tabs.api.md +++ b/tools/public_api_guard/components/Tabs.api.md @@ -5,12 +5,18 @@ ```ts import type { AriaTabListProps } from '@koobiq/react-primitives'; +import type { ButtonBaseProps } from '@koobiq/react-primitives'; +import { ButtonProps } from '@koobiq/react-primitives'; import type { ComponentProps } from 'react'; import type { ComponentPropsWithRef } from 'react'; import type { ComponentRef } from 'react'; import type { CSSProperties } from 'react'; +import type { ElementType } from 'react'; +import type { ExtendableProps } from '@koobiq/react-core'; import type { ItemProps } from '@koobiq/react-core'; import { JSX } from 'react/jsx-runtime'; +import type { Key } from '@koobiq/react-core'; +import { PolyForwardComponent } from '@koobiq/react-core'; import type { ReactElement } from 'react'; import type { ReactNode } from 'react'; import type { Ref } from 'react'; @@ -53,11 +59,15 @@ export type TabsProps = AriaTabListProps & { className?: string; style?: CSSProperties; isStretched?: boolean; + onRemove?: (keys: Set) => void; + onAdd?: () => void; slotProps?: { tabs?: ComponentProps<'div'>; tabList?: ComponentProps<'div'>; tabPanel?: ComponentProps<'div'>; scrollBox?: ComponentProps<'div'>; + addButton?: TabAddButtonProps; + closeButton?: IconButtonProps; indicator?: ComponentProps<'span'>; }; isUnderlined?: boolean; @@ -70,6 +80,11 @@ export type TabsRef = ComponentRef<'div'>; // @public (undocumented) export function TabsRender(props: Omit, 'ref'>, ref: Ref): JSX.Element; +// Warnings were encountered during analysis: +// +// packages/components/dist/components/Tabs/types.d.ts:28:9 - (ae-forgotten-export) The symbol "TabAddButtonProps" needs to be exported by the entry point index.d.ts +// packages/components/dist/components/Tabs/types.d.ts:29:9 - (ae-forgotten-export) The symbol "IconButtonProps" needs to be exported by the entry point index.d.ts + // (No @packageDocumentation comment for this package) ```