From 3bae7553130a0ff20f6a6bc641ee2396fc0ac7c0 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 29 Jun 2026 21:09:54 +0300 Subject: [PATCH 01/37] feat(Tabs): initial implementation --- .../components/src/components/Tabs/Tabs.mdx | 15 ++ .../src/components/Tabs/Tabs.module.css | 154 +++++++++++++++--- .../src/components/Tabs/Tabs.stories.tsx | 88 +++++++++- .../src/components/Tabs/Tabs.test.tsx | 87 +++++++++- .../components/src/components/Tabs/Tabs.tsx | 113 +++++++++---- .../components/Tabs/components/Tab/Tab.tsx | 55 ++++++- .../components/TabAddButton/TabAddButton.tsx | 37 +++++ .../Tabs/components/TabAddButton/index.ts | 1 + .../src/components/Tabs/components/index.ts | 1 + .../components/src/components/Tabs/intl.json | 8 +- .../components/src/components/Tabs/types.ts | 11 ++ 11 files changed, 508 insertions(+), 62 deletions(-) create mode 100644 packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx create mode 100644 packages/components/src/components/Tabs/components/TabAddButton/index.ts diff --git a/packages/components/src/components/Tabs/Tabs.mdx b/packages/components/src/components/Tabs/Tabs.mdx index 612e14fee..7fa5177a6 100644 --- a/packages/components/src/components/Tabs/Tabs.mdx +++ b/packages/components/src/components/Tabs/Tabs.mdx @@ -40,6 +40,21 @@ You can render tabs dynamically by using `items` prop. +## Editable + +Provide `onRemove` to let users delete tabs and `onAdd` to let them create new ones. +`Tabs` doesn't own the collection — you manage it (for example with `useListData`) and +`Tabs` just calls these handlers: + +- `onRemove(keys: Set)` follows the React Aria removable-collection convention + (`onRemove={(keys) => list.remove(...keys)}`). When provided, a close (×) button appears + on every tab; tabs can also be removed with `Delete` / `Backspace`. +- `onAdd()` renders a trailing add ("+") button. In horizontal orientation it stays pinned + to the right while tabs scroll; in vertical orientation it becomes a full-width button + pinned to the bottom, and the scrollable area fades out at the edges that overflow. + + + ## Vertical Set the tabs to vertical by passing `orientation="vertical"` to `Tabs`. diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index 5653ef8e2..af2945d9d 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -5,24 +5,21 @@ position: relative; } +.scrollArea { + display: flex; + position: relative; + flex: 0 1 auto; + min-inline-size: 0; +} + /* container */ .container { display: flex; gap: var(--kbq-size-m); } -.default { - .tab { - min-inline-size: 40px; - - &.selected .content { - background-color: var(--kbq-states-background-transparent-active); - } - - &.hovered .content { - background-color: var(--kbq-states-background-transparent-hover); - } - } +.default .tab { + min-inline-size: 40px; } .underlined { @@ -36,7 +33,8 @@ z-index: var(--kbq-layer-absolute); } - .tab { + .tab, + .addButton { --tab-content-offset: var(--kbq-size-m); min-inline-size: 40px; @@ -63,7 +61,8 @@ } } - .tab.onlyIcon { + .tab.onlyIcon, + .addButton { --tab-content-offset: 0px; min-inline-size: 32px; @@ -78,16 +77,17 @@ border-inline-end: 0; } - .tab.selected { - &::after { - background-color: var(--kbq-line-contrast); - } + .addButton { + border-inline-end: 0; } - .tab.hovered { - &::after { - background-color: var(--kbq-line-contrast-fade); - } + .tab.selected::after { + background-color: var(--kbq-line-contrast); + } + + .tab.hovered::after, + .addButton[data-hovered]::after { + background-color: var(--kbq-line-contrast-fade); } } @@ -186,15 +186,52 @@ } } -.focusVisible .content { +/* interactive surface — shared by tabs and the add (+) button */ +.default .tab[data-hovered] .content, +.default .addButton[data-hovered] .content { + background-color: var(--kbq-states-background-transparent-hover); +} + +.default .tab[data-selected] .content, +.default .addButton[data-pressed] .content { + background-color: var(--kbq-states-background-transparent-active); +} + +.tab[data-focus-visible] .content, +.addButton[data-focus-visible] .content { outline-color: var(--kbq-states-line-focus-theme); } -.disabled .content { +.tab[data-disabled] .content, +.addButton[data-disabled] .content { color: var(--kbq-states-foreground-disabled); cursor: default; } +/* editable: add (+) button — native button reset, reuses .content + the surface above */ +.addButton { + 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; +} + +.addButton .content { + justify-content: center; +} + +.vertical .addButton { + inline-size: 100%; +} + .content { display: flex; gap: var(--kbq-size-xxs); @@ -222,3 +259,72 @@ display: flex; align-items: center; } + +/* editable: close (×) button */ +.closeButton { + display: flex; + opacity: 0; + position: absolute; + visibility: hidden; + inset-block-start: 50%; + inset-inline-end: var(--kbq-size-xs); + transform: translateY(-50%); + z-index: calc(var(--kbq-layer-absolute) + 1); + transition: + opacity var(--kbq-transition-default), + visibility var(--kbq-transition-default); +} + +.tab[data-hovered] .closeButton, +.tab[data-selected] .closeButton, +.tab[data-focus-visible] .closeButton { + opacity: 1; + visibility: visible; +} + +/* editable: vertical create mode (full-width add button below + sticky) */ +.vertical .base { + flex-direction: column; + min-block-size: 0; +} + +.vertical .scrollArea { + flex: 1 1 auto; + min-block-size: 0; +} + +/* editable: full-width scroll box + vertical fade mask (shown only on the overflowing side) */ +.vertical[data-editable] .scrollBox { + --tabs-fade-size: var(--kbq-size-3xl); + + display: block; + inline-size: 100%; + block-size: 100%; + scrollbar-width: none; + + &[data-overflow-block-start][data-overflow-block-end] { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--tabs-fade-size), + #000 calc(100% - var(--tabs-fade-size)), + transparent 100% + ); + } + + &[data-overflow-block-start]:not([data-overflow-block-end]) { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--tabs-fade-size) + ); + } + + &:not([data-overflow-block-start])[data-overflow-block-end] { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--tabs-fade-size)), + transparent 100% + ); + } +} diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 3f7b2db3b..709ac43ff 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,88 @@ export const Dynamic: Story = { }, }; +export const Editable: Story = { + render: function Render() { + const [isVertical, { set: setVertical }] = useBoolean(false); + const [isUnderlined, { set: setUnderlined }] = 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 + + + + {(item) => ( + } + endAddon={} + key={item.id} + title={item.title} + > + {item.title} content + + )} + + + ); + }, +}; + export const WithIcons: Story = { render: function Render(args) { const [isVertical, { set }] = useBoolean(false); diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index 58cb9ea4b..f7569ed7b 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'; @@ -351,3 +351,88 @@ 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); + }); +}); diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index f32f93450..a9a674f9b 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,8 @@ export function TabsRender( 'data-testid': dataTestId, isUnderlined = false, isStretched: isStretchedProp = false, + onRemove, + onAdd, style, className, slotProps, @@ -52,9 +59,16 @@ export function TabsRender( next: 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 selectedItemIdx = selectedItem?.index; const selectedTabProps = selectedItem?.props as TabItemProps | undefined; @@ -127,6 +141,29 @@ export function TabsRender( }); }; + /** Syncs the vertical fade mask with the current scroll position. */ + const updateVerticalOverflow = () => { + if (isHorizontal) return; + + const el = scrollBoxRef.current; + if (!el) return; + + const start = el.scrollTop > 0; + const end = Math.ceil(el.scrollTop + el.clientHeight) < el.scrollHeight; + + setVerticalOverflow((prevState) => + prevState.start === start && prevState.end === end + ? prevState + : { start, end } + ); + }; + + /** Syncs both scroll affordances: horizontal buttons and vertical fade. */ + const updateScrollState = () => { + updateScrollButtonsVisibility(); + updateVerticalOverflow(); + }; + const hasHScroll = isHorizontal && tabListRef.current && @@ -192,7 +229,7 @@ export function TabsRender( }; const [debouncedUpdateScrollButtonsActivity] = useDebounceCallback({ - callback: updateScrollButtonsVisibility, + callback: updateScrollState, delay: 100, }); @@ -228,7 +265,7 @@ export function TabsRender( if (isMounted) { debouncedScrollCorrection(orientation); } else { - updateScrollButtonsVisibility(); + updateScrollState(); setIsMounted(true); } }, [selectedItemIdx, isMounted]); @@ -248,7 +285,9 @@ export function TabsRender( { ref: scrollBoxRef, className: s.scrollBox, - onScroll: updateScrollButtonsVisibility, + onScroll: updateScrollState, + 'data-overflow-block-start': verticalOverflow.start || undefined, + 'data-overflow-block-end': verticalOverflow.end || undefined, }, slotProps?.scrollBox ); @@ -259,6 +298,7 @@ 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} @@ -273,35 +313,48 @@ export function TabsRender( )} >
- {hasHScroll && ( - <> - - - - )} -
-
- {[...state.collection].map((item, i) => ( - scrollCorrection(orientation, i, 'auto')} +
+ {hasHScroll && ( + <> + - ))} + + + )} +
+
+ {[...state.collection].map((item, i) => ( + onRemove?.(new Set([item.key]))} + onFocused={() => scrollCorrection(orientation, i, 'auto')} + /> + ))} +
+ {hasAddButton && ( + onAdd?.()} + {...slotProps?.addButton} + /> + )}
{hasSelectedTabPanel && ( = { state: TabListState; innerRef: Ref; onFocused?: () => void; + isRemovable?: boolean; + onRemove?: () => void; + removeLabel?: string; + closeButtonProps?: IconButtonProps; }; -export function Tab({ item, state, innerRef, onFocused }: TabProps) { +export function Tab({ + item, + state, + innerRef, + onFocused, + isRemovable = false, + onRemove, + removeLabel, + closeButtonProps, +}: TabProps) { const { key, rendered } = item; const domRef = useDOMRef(innerRef); @@ -55,6 +70,15 @@ export function Tab({ item, state, innerRef, onFocused }: TabProps) { const Tag: ElementType = href ? 'a' : 'div'; + const onKeyDown = (event: KeyboardEvent) => { + if (!isRemovable || isDisabled) return; + + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + onRemove?.(); + } + }; + return ( ({ item, state, innerRef, onFocused }: TabProps) { data-disabled={isDisabled || undefined} data-selected={isSelected || undefined} data-onlyicon={onlyIcon || undefined} + data-closable={isRemovable || undefined} data-focus-visible={isFocusVisible || undefined} - {...mergeProps(hoverProps, focusProps, tabProps, { onFocus: onFocused })} + {...mergeProps(hoverProps, focusProps, tabProps, { + onFocus: onFocused, + onKeyDown, + })} ref={domRef as any} >
@@ -83,6 +111,27 @@ export function Tab({ item, state, innerRef, onFocused }: TabProps) { )} {isNotNil(endAddon) &&
{endAddon}
}
+ {isRemovable && ( + event.stopPropagation()} + > + onRemove?.()} + {...closeButtonProps} + > + + + + )}
); } 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..f6db175ff --- /dev/null +++ b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx @@ -0,0 +1,37 @@ +import { type ComponentRef, forwardRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; +import { IconPlus16 } from '@koobiq/react-icons'; +import { + Button as ButtonPrimitive, + type ButtonBaseProps, +} from '@koobiq/react-primitives'; + +import s from '../../Tabs.module.css'; + +export type TabAddButtonProps = Omit & { + className?: string; +}; + +/** + * The trailing add ("+") button for editable tabs. Built on the primitive + * Button and styled with the shared tab surface, so it matches tabs in every + * state (hover / focus / pressed / disabled). + */ +export const TabAddButton = forwardRef< + ComponentRef<'button'>, + TabAddButtonProps +>(({ className, ...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/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'>; }; From 0ae44f04a70267503a8aeb9003d957f2a63a3a9c Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 30 Jun 2026 13:42:01 +0300 Subject: [PATCH 02/37] fix(Tabs): add transparency mask under close button --- .../src/components/Tabs/Tabs.module.css | 56 ++++++++++++++----- .../components/Tabs/components/Tab/Tab.tsx | 18 +++--- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index af2945d9d..b0507b46e 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -30,7 +30,7 @@ position: absolute; block-size: var(--kbq-size-border-width); background: var(--kbq-line-contrast-less); - z-index: var(--kbq-layer-absolute); + z-index: calc(var(--kbq-layer-absolute) + 3); } .tab, @@ -89,6 +89,10 @@ .addButton[data-hovered]::after { background-color: var(--kbq-line-contrast-fade); } + + .closeButton { + inset-inline-end: 0; + } } .horizontal { @@ -105,7 +109,7 @@ flex-direction: row; } - .tab .content { + .tab .inner { justify-content: center; } } @@ -124,19 +128,19 @@ .tab { inline-size: 100%; - padding-block: var(--kbq-size-xxs); + border-block: var(--kbq-size-xxs) solid var(--kbq-background-transparent); - .content { + .inner { justify-content: flex-start; } } .tab:first-child { - padding-block-start: 0; + border-block-start: 0; } .tab:last-child { - padding-block-end: 0; + border-block-end: 0; } .label { @@ -147,6 +151,10 @@ } .stretched { + .scrollArea { + inline-size: 100%; + } + .scrollBox { overflow: hidden; } @@ -234,11 +242,8 @@ .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); @@ -254,6 +259,18 @@ color var(--kbq-transition-default); } +/* inner content wrapper — holds addons + label, masked under the close (×) button */ +.inner { + display: flex; + flex: 1; + position: relative; + gap: var(--kbq-size-xxs); + overflow: hidden; + min-inline-size: 0; + align-items: center; + white-space: nowrap; +} + .addon { flex-shrink: 0; display: flex; @@ -267,21 +284,32 @@ position: absolute; visibility: hidden; inset-block-start: 50%; - inset-inline-end: var(--kbq-size-xs); + inset-inline-end: var(--kbq-size-m); transform: translateY(-50%); z-index: calc(var(--kbq-layer-absolute) + 1); - transition: - opacity var(--kbq-transition-default), - visibility var(--kbq-transition-default); } .tab[data-hovered] .closeButton, -.tab[data-selected] .closeButton, .tab[data-focus-visible] .closeButton { opacity: 1; visibility: visible; } +/* editable: fade the content out under the close (×) button while it is shown */ +.tab[data-closable][data-hovered] .inner, +.tab[data-closable][data-focus-visible] .inner { + --tabs-close-fade-size: var(--kbq-size-l); + + /* shift the fade inward by the underlined indicator offset so it lines up with × in both variants */ + --tabs-close-fade-inset: var(--tab-content-offset, 0px); + + mask-image: linear-gradient( + to right, + #000 calc(100% - var(--tabs-close-fade-size) * 2), + transparent calc(100% - var(--tabs-close-fade-size)) + ); +} + /* editable: vertical create mode (full-width add button below + sticky) */ .vertical .base { flex-direction: column; diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.tsx b/packages/components/src/components/Tabs/components/Tab/Tab.tsx index c1c6efbba..ab753b272 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.tsx +++ b/packages/components/src/components/Tabs/components/Tab/Tab.tsx @@ -4,7 +4,7 @@ import type { ElementType, KeyboardEvent, Ref } from 'react'; import { type Node, isNotNil, useFocusRing } from '@koobiq/react-core'; import { useHover, mergeProps, clsx, useDOMRef } from '@koobiq/react-core'; -import { IconXmarkS16 } from '@koobiq/react-icons'; +import { IconXmark16 } from '@koobiq/react-icons'; import type { TabListState } from '@koobiq/react-primitives'; import { useTab } from '@koobiq/react-primitives'; @@ -53,7 +53,7 @@ export function Tab({ Boolean(onlyIconProp) && (isNotNil(startAddon) || isNotNil(endAddon)); const { hoverProps, isHovered } = useHover({ - isDisabled: isDisabled || isSelected, + isDisabled: isDisabled, }); const { isFocusVisible, focusProps } = useFocusRing(); @@ -105,11 +105,13 @@ export function Tab({ ref={domRef as any} >
- {isNotNil(startAddon) &&
{startAddon}
} - {!onlyIcon && isNotNil(rendered) && ( -
{rendered}
- )} - {isNotNil(endAddon) &&
{endAddon}
} + + {isNotNil(startAddon) &&
{startAddon}
} + {!onlyIcon && isNotNil(rendered) && ( +
{rendered}
+ )} + {isNotNil(endAddon) &&
{endAddon}
} +
{isRemovable && ( ({ onPress={() => onRemove?.()} {...closeButtonProps} > - + )} From 61149d2865b464e421ad5f68cdce5242d474b335 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 30 Jun 2026 13:49:53 +0300 Subject: [PATCH 03/37] refactor(Tab): simplify component --- .../components/src/components/Tabs/Tabs.tsx | 7 ++++--- .../src/components/Tabs/components/Tab/Tab.tsx | 17 +++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index a9a674f9b..a783d0759 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -338,10 +338,11 @@ export function TabsRender( state={state} key={item.key} innerRef={itemsRefs[i]} - isRemovable={isRemovable} - removeLabel={t.format('remove')} closeButtonProps={slotProps?.closeButton} - onRemove={() => onRemove?.(new Set([item.key]))} + {...(onRemove && + typeof onRemove === 'function' && { + onRemove: () => onRemove?.(new Set([item.key])), + })} onFocused={() => scrollCorrection(orientation, i, 'auto')} /> ))} diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.tsx b/packages/components/src/components/Tabs/components/Tab/Tab.tsx index ab753b272..fd7017e0e 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.tsx +++ b/packages/components/src/components/Tabs/components/Tab/Tab.tsx @@ -2,13 +2,19 @@ import type { ElementType, KeyboardEvent, Ref } from 'react'; -import { type Node, isNotNil, useFocusRing } from '@koobiq/react-core'; +import { + type Node, + isNotNil, + useFocusRing, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; import { useHover, mergeProps, clsx, useDOMRef } from '@koobiq/react-core'; import { IconXmark16 } from '@koobiq/react-icons'; import type { TabListState } from '@koobiq/react-primitives'; import { useTab } from '@koobiq/react-primitives'; import { IconButton, type IconButtonProps } from '../../../IconButton'; +import intlMessages from '../../intl.json'; import type { TabProps as TabItemProps } from '../../Tab'; import s from '../../Tabs.module.css'; @@ -17,9 +23,7 @@ export type TabProps = { state: TabListState; innerRef: Ref; onFocused?: () => void; - isRemovable?: boolean; onRemove?: () => void; - removeLabel?: string; closeButtonProps?: IconButtonProps; }; @@ -28,12 +32,13 @@ export function Tab({ state, innerRef, onFocused, - isRemovable = false, onRemove, - removeLabel, closeButtonProps, }: TabProps) { const { key, rendered } = item; + const t = useLocalizedStringFormatter(intlMessages); + + const isRemovable = !!onRemove; const domRef = useDOMRef(innerRef); const { tabProps, isSelected, isDisabled } = useTab({ key }, state, domRef); @@ -125,7 +130,7 @@ export function Tab({ tabIndex={-1} variant="fade-contrast" data-slot="close-button" - aria-label={removeLabel} + aria-label={t.format('remove')} isDisabled={isDisabled} onPress={() => onRemove?.()} {...closeButtonProps} From 399d0f60bdcf5c6837fe1d2c9b3645167deb5e4c Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 30 Jun 2026 14:49:29 +0300 Subject: [PATCH 04/37] feat(Tabs): add transparency mask under next and previous buttons --- .../src/components/Tabs/Tabs.module.css | 49 ++++++++++++++----- .../components/src/components/Tabs/Tabs.tsx | 27 +++++++++- .../TabScrollButton.module.css | 31 ------------ 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index b0507b46e..7bb258504 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -298,15 +298,12 @@ /* editable: fade the content out under the close (×) button while it is shown */ .tab[data-closable][data-hovered] .inner, .tab[data-closable][data-focus-visible] .inner { - --tabs-close-fade-size: var(--kbq-size-l); - - /* shift the fade inward by the underlined indicator offset so it lines up with × in both variants */ - --tabs-close-fade-inset: var(--tab-content-offset, 0px); + --mask-size: var(--kbq-size-l); mask-image: linear-gradient( to right, - #000 calc(100% - var(--tabs-close-fade-size) * 2), - transparent calc(100% - var(--tabs-close-fade-size)) + #000 calc(100% - var(--mask-size) * 2), + transparent calc(100% - var(--mask-size)) ); } @@ -321,9 +318,39 @@ min-block-size: 0; } +.scrollBox { + --mask-size: 32px; + + &[data-overflow-inline-start='true'] { + mask-image: linear-gradient( + to right, + transparent var(--mask-size), + #000 calc(var(--mask-size) * 1.5) + ); + } + + &[data-overflow-inline-end='true'] { + mask-image: linear-gradient( + to right, + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); + } + + &[data-overflow-inline-start='true'][data-overflow-inline-end='true'] { + 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)) + ); + } +} + /* editable: full-width scroll box + vertical fade mask (shown only on the overflowing side) */ .vertical[data-editable] .scrollBox { - --tabs-fade-size: var(--kbq-size-3xl); + --mask-size: var(--kbq-size-3xl); display: block; inline-size: 100%; @@ -334,8 +361,8 @@ mask-image: linear-gradient( to bottom, transparent 0, - #000 var(--tabs-fade-size), - #000 calc(100% - var(--tabs-fade-size)), + #000 var(--mask-size), + #000 calc(100% - var(--mask-size)), transparent 100% ); } @@ -344,14 +371,14 @@ mask-image: linear-gradient( to bottom, transparent 0, - #000 var(--tabs-fade-size) + #000 var(--mask-size) ); } &:not([data-overflow-block-start])[data-overflow-block-end] { mask-image: linear-gradient( to bottom, - #000 calc(100% - var(--tabs-fade-size)), + #000 calc(100% - var(--mask-size)), transparent 100% ); } diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index a783d0759..105a4f968 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -59,6 +59,11 @@ export function TabsRender( next: false, }); + const [horizontalOverflow, setHorizontalOverflow] = useState({ + start: false, + end: false, + }); + const [verticalOverflow, setVerticalOverflow] = useState({ start: false, end: false, @@ -141,6 +146,23 @@ export function TabsRender( }); }; + /** Syncs the horizontal fade mask with the current scroll position. */ + const updateHorizontalOverflow = () => { + if (!isHorizontal) return; + + const el = scrollBoxRef.current; + if (!el) return; + + const start = el.scrollLeft > 0; + const end = Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth; + + setHorizontalOverflow((prevState) => + prevState.start === start && prevState.end === end + ? prevState + : { start, end } + ); + }; + /** Syncs the vertical fade mask with the current scroll position. */ const updateVerticalOverflow = () => { if (isHorizontal) return; @@ -158,9 +180,10 @@ export function TabsRender( ); }; - /** Syncs both scroll affordances: horizontal buttons and vertical fade. */ + /** Syncs both scroll affordances: horizontal buttons and horizontal/vertical fade. */ const updateScrollState = () => { updateScrollButtonsVisibility(); + updateHorizontalOverflow(); updateVerticalOverflow(); }; @@ -286,6 +309,8 @@ export function TabsRender( ref: scrollBoxRef, className: s.scrollBox, onScroll: updateScrollState, + 'data-overflow-inline-start': horizontalOverflow.start || undefined, + 'data-overflow-inline-end': horizontalOverflow.end || undefined, 'data-overflow-block-start': verticalOverflow.start || undefined, 'data-overflow-block-end': verticalOverflow.end || undefined, }, 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..e8607e3cc 100644 --- a/packages/components/src/components/Tabs/components/TabScrollButton/TabScrollButton.module.css +++ b/packages/components/src/components/Tabs/components/TabScrollButton/TabScrollButton.module.css @@ -8,43 +8,12 @@ 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 { From 01338996994172dfc17a8f08227219aec36a11ed Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 30 Jun 2026 23:06:43 +0300 Subject: [PATCH 05/37] refactor(Tabs): colocate tab styles with subcomponents --- .../src/components/Tabs/Tabs.module.css | 386 ++++-------------- .../components/src/components/Tabs/Tabs.tsx | 6 +- .../Tabs/components/Tab/Tab.module.css | 193 +++++++++ .../components/Tabs/components/Tab/Tab.tsx | 23 +- .../TabAddButton/TabAddButton.module.css | 89 ++++ .../components/TabAddButton/TabAddButton.tsx | 52 ++- 6 files changed, 414 insertions(+), 335 deletions(-) create mode 100644 packages/components/src/components/Tabs/components/Tab/Tab.module.css create mode 100644 packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index 7bb258504..902f31e47 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -1,4 +1,7 @@ -@import url('../../styles/mixins.css'); +.container { + display: flex; + gap: var(--kbq-size-m); +} .base { display: flex; @@ -12,14 +15,40 @@ min-inline-size: 0; } -/* container */ -.container { - display: flex; - gap: var(--kbq-size-m); +.scrollBox { + --mask-size: 32px; + + &[data-overflow-inline-start='true'] { + mask-image: linear-gradient( + to right, + transparent var(--mask-size), + #000 calc(var(--mask-size) * 1.5) + ); + } + + &[data-overflow-inline-end='true'] { + mask-image: linear-gradient( + to right, + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); + } + + &[data-overflow-inline-start='true'][data-overflow-inline-end='true'] { + 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)) + ); + } } -.default .tab { - min-inline-size: 40px; +.tabList { + flex-grow: 1; + display: flex; + position: relative; } .underlined { @@ -32,67 +61,6 @@ background: var(--kbq-line-contrast-less); z-index: calc(var(--kbq-layer-absolute) + 3); } - - .tab, - .addButton { - --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, - .addButton { - --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; - } - - .addButton { - border-inline-end: 0; - } - - .tab.selected::after { - background-color: var(--kbq-line-contrast); - } - - .tab.hovered::after, - .addButton[data-hovered]::after { - background-color: var(--kbq-line-contrast-fade); - } - - .closeButton { - inset-inline-end: 0; - } } .horizontal { @@ -108,13 +76,19 @@ .tabList { flex-direction: row; } - - .tab .inner { - justify-content: center; - } } .vertical { + .base { + flex-direction: column; + min-block-size: 0; + } + + .scrollArea { + flex: 1 1 auto; + min-block-size: 0; + } + .scrollBox { display: inline-block; inline-size: auto; @@ -126,27 +100,39 @@ flex-direction: column; } - .tab { + &[data-editable] .scrollBox { + --mask-size: var(--kbq-size-3xl); + + display: block; inline-size: 100%; - border-block: var(--kbq-size-xxs) solid var(--kbq-background-transparent); + block-size: 100%; + scrollbar-width: none; - .inner { - justify-content: flex-start; + &[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% + ); } - } - .tab:first-child { - border-block-start: 0; - } - - .tab:last-child { - border-block-end: 0; - } - - .label { - min-inline-size: 0; + &[data-overflow-block-start]:not([data-overflow-block-end]) { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--mask-size) + ); + } - @mixin ellipsis; + &:not([data-overflow-block-start])[data-overflow-block-end] { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--mask-size)), + transparent 100% + ); + } } } @@ -158,228 +144,4 @@ .scrollBox { overflow: hidden; } - - .tab { - 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); - } -} - -/* interactive surface — shared by tabs and the add (+) button */ -.default .tab[data-hovered] .content, -.default .addButton[data-hovered] .content { - background-color: var(--kbq-states-background-transparent-hover); -} - -.default .tab[data-selected] .content, -.default .addButton[data-pressed] .content { - background-color: var(--kbq-states-background-transparent-active); -} - -.tab[data-focus-visible] .content, -.addButton[data-focus-visible] .content { - outline-color: var(--kbq-states-line-focus-theme); -} - -.tab[data-disabled] .content, -.addButton[data-disabled] .content { - color: var(--kbq-states-foreground-disabled); - cursor: default; -} - -/* editable: add (+) button — native button reset, reuses .content + the surface above */ -.addButton { - 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; -} - -.addButton .content { - justify-content: center; -} - -.vertical .addButton { - inline-size: 100%; -} - -.content { - display: flex; - align-items: center; - inline-size: 100%; - 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); -} - -/* inner content wrapper — holds addons + label, masked under the close (×) button */ -.inner { - display: flex; - flex: 1; - position: relative; - gap: var(--kbq-size-xxs); - overflow: hidden; - min-inline-size: 0; - align-items: center; - white-space: nowrap; -} - -.addon { - flex-shrink: 0; - display: flex; - align-items: center; -} - -/* editable: close (×) button */ -.closeButton { - display: flex; - opacity: 0; - position: absolute; - visibility: hidden; - inset-block-start: 50%; - inset-inline-end: var(--kbq-size-m); - transform: translateY(-50%); - z-index: calc(var(--kbq-layer-absolute) + 1); -} - -.tab[data-hovered] .closeButton, -.tab[data-focus-visible] .closeButton { - opacity: 1; - visibility: visible; -} - -/* editable: fade the content out under the close (×) button while it is shown */ -.tab[data-closable][data-hovered] .inner, -.tab[data-closable][data-focus-visible] .inner { - --mask-size: var(--kbq-size-l); - - mask-image: linear-gradient( - to right, - #000 calc(100% - var(--mask-size) * 2), - transparent calc(100% - var(--mask-size)) - ); -} - -/* editable: vertical create mode (full-width add button below + sticky) */ -.vertical .base { - flex-direction: column; - min-block-size: 0; -} - -.vertical .scrollArea { - flex: 1 1 auto; - min-block-size: 0; -} - -.scrollBox { - --mask-size: 32px; - - &[data-overflow-inline-start='true'] { - mask-image: linear-gradient( - to right, - transparent var(--mask-size), - #000 calc(var(--mask-size) * 1.5) - ); - } - - &[data-overflow-inline-end='true'] { - mask-image: linear-gradient( - to right, - #000 calc(100% - var(--mask-size) * 1.5), - transparent calc(100% - var(--mask-size)) - ); - } - - &[data-overflow-inline-start='true'][data-overflow-inline-end='true'] { - 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)) - ); - } -} - -/* editable: full-width scroll box + vertical fade mask (shown only on the overflowing side) */ -.vertical[data-editable] .scrollBox { - --mask-size: var(--kbq-size-3xl); - - display: block; - inline-size: 100%; - block-size: 100%; - scrollbar-width: none; - - &[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% - ); - } - - &[data-overflow-block-start]:not([data-overflow-block-end]) { - mask-image: linear-gradient( - to bottom, - transparent 0, - #000 var(--mask-size) - ); - } - - &:not([data-overflow-block-start])[data-overflow-block-end] { - mask-image: linear-gradient( - to bottom, - #000 calc(100% - var(--mask-size)), - transparent 100% - ); - } } diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index 105a4f968..1f60a0fd8 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -330,7 +330,6 @@ export function TabsRender( data-horizontal-scrollable={hasHScroll || undefined} className={clsx( s.container, - !isUnderlined && s.default, isStretched && s.stretched, isUnderlined && s.underlined, orientation && s[orientation], @@ -363,6 +362,9 @@ export function TabsRender( state={state} key={item.key} innerRef={itemsRefs[i]} + orientation={orientation} + isStretched={isStretched} + isUnderlined={isUnderlined} closeButtonProps={slotProps?.closeButton} {...(onRemove && typeof onRemove === 'function' && { @@ -379,6 +381,8 @@ export function TabsRender( aria-label={t.format('add')} onPress={() => onAdd?.()} {...slotProps?.addButton} + orientation={orientation} + isUnderlined={isUnderlined} /> )}
diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css new file mode 100644 index 000000000..cbf847cf4 --- /dev/null +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -0,0 +1,193 @@ +@import url('../../../../styles/mixins.css'); + +.base { + 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); + } + + &[data-focus-visible] .content { + outline-color: var(--kbq-states-line-focus-theme); + } + + &[data-disabled] .content { + color: var(--kbq-states-foreground-disabled); + cursor: default; + } + + &[data-hovered] .closeButton, + &[data-focus-visible] .closeButton { + opacity: 1; + visibility: visible; + } + + &[data-closable][data-hovered] .inner, + &[data-closable][data-focus-visible] .inner { + --mask-size: var(--kbq-size-l); + + mask-image: linear-gradient( + to right, + #000 calc(100% - var(--mask-size) * 2), + transparent calc(100% - var(--mask-size)) + ); + } +} + +.default { + min-inline-size: 40px; + + &[data-hovered] .content { + background-color: var(--kbq-states-background-transparent-hover); + } + + &[data-selected] .content { + background-color: var(--kbq-states-background-transparent-active); + } +} + +.underlined { + --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; + + &::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); + } + + &:first-child { + border-inline-start: 0; + } + + &:last-child { + border-inline-end: 0; + } + + &[data-selected]::after { + background-color: var(--kbq-line-contrast); + } + + &[data-hovered]::after { + background-color: var(--kbq-line-contrast-fade); + } + + &.onlyIcon { + --tab-content-offset: 0px; + + min-inline-size: 32px; + border-inline: var(--kbq-size-xs) 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))); + } + + .closeButton { + inset-inline-end: 0; + } +} + +.horizontal .inner { + justify-content: center; +} + +.vertical { + inline-size: 100%; + border-block: var(--kbq-size-xxs) solid var(--kbq-background-transparent); + + &:first-child { + border-block-start: 0; + } + + &:last-child { + border-block-end: 0; + } + + .inner { + justify-content: flex-start; + } + + .label { + min-inline-size: 0; + + @mixin ellipsis; + } +} + +.stretched { + inline-size: 100%; + + .label { + min-inline-size: 0; + + @mixin ellipsis; + } +} + +.content { + display: flex; + align-items: center; + inline-size: 100%; + 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); +} + +.inner { + display: flex; + flex: 1; + position: relative; + gap: var(--kbq-size-xxs); + overflow: hidden; + min-inline-size: 0; + align-items: center; + white-space: nowrap; +} + +.addon { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.closeButton { + display: flex; + opacity: 0; + position: absolute; + visibility: hidden; + inset-block-start: 50%; + inset-inline-end: var(--kbq-size-m); + transform: translateY(-50%); + z-index: calc(var(--kbq-layer-absolute) + 1); +} diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.tsx b/packages/components/src/components/Tabs/components/Tab/Tab.tsx index fd7017e0e..5ebbee309 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.tsx +++ b/packages/components/src/components/Tabs/components/Tab/Tab.tsx @@ -16,12 +16,18 @@ import { useTab } from '@koobiq/react-primitives'; import { IconButton, type IconButtonProps } from '../../../IconButton'; import intlMessages from '../../intl.json'; import type { TabProps as TabItemProps } from '../../Tab'; -import s from '../../Tabs.module.css'; + +import s from './Tab.module.css'; + +type TabOrientation = 'horizontal' | 'vertical'; export type TabProps = { item: Node; state: TabListState; innerRef: Ref; + orientation: TabOrientation; + isUnderlined?: boolean; + isStretched?: boolean; onFocused?: () => void; onRemove?: () => void; closeButtonProps?: IconButtonProps; @@ -31,6 +37,9 @@ export function Tab({ item, state, innerRef, + orientation, + isUnderlined = false, + isStretched = false, onFocused, onRemove, closeButtonProps, @@ -88,20 +97,22 @@ export function Tab({ & { 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 shared tab surface, so it matches tabs in every - * state (hover / focus / pressed / disabled). + * Button and styled with the tab surface states, so it matches tabs in every + * state. */ export const TabAddButton = forwardRef< ComponentRef<'button'>, - TabAddButtonProps ->(({ className, ...other }, ref) => ( - - - - - -)); + TabAddButtonViewProps +>( + ( + { className, orientation = 'horizontal', isUnderlined = false, ...other }, + ref + ) => ( + + + + + + ) +); TabAddButton.displayName = 'TabAddButton'; From 814d10a7c685597e9b849abd523eb125981ed4f2 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 10:20:26 +0300 Subject: [PATCH 06/37] fix(Tab): fix css-layout for onlyIcon state --- .../components/Tabs/components/Tab/Tab.module.css | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index cbf847cf4..4e9835c73 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -9,6 +9,7 @@ text-decoration: none; box-sizing: border-box; justify-content: center; + min-inline-size: 40px; transition: background-color var(--kbq-transition-default); &::after { @@ -43,8 +44,6 @@ } .default { - min-inline-size: 40px; - &[data-hovered] .content { background-color: var(--kbq-states-background-transparent-hover); } @@ -52,12 +51,15 @@ &[data-selected] .content { background-color: var(--kbq-states-background-transparent-active); } + + &.onlyIcon .content { + padding-inline: var(--kbq-size-s); + } } .underlined { --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; @@ -93,7 +95,6 @@ &.onlyIcon { --tab-content-offset: 0px; - min-inline-size: 32px; border-inline: var(--kbq-size-xs) solid transparent; } @@ -145,6 +146,10 @@ } } +.onlyIcon { + min-inline-size: 32px; +} + .content { display: flex; align-items: center; From 56fe2ff458f0318c79ec64ebf6ef1a4924e22041 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 10:35:25 +0300 Subject: [PATCH 07/37] fix(Tabs): sync overflow attributes with orientation --- .../src/components/Tabs/Tabs.test.tsx | 108 ++++++++++++++---- .../components/src/components/Tabs/Tabs.tsx | 43 +++---- 2 files changed, 106 insertions(+), 45 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index f7569ed7b..7987fc9df 100644 --- a/packages/components/src/components/Tabs/Tabs.test.tsx +++ b/packages/components/src/components/Tabs/Tabs.test.tsx @@ -190,61 +190,121 @@ 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(); @@ -302,13 +362,12 @@ describe('Tabs', () => { 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 +375,7 @@ describe('Tabs', () => { writable: true, }); - rerender(renderComponent({})); + if (scrollBox) fireEvent.scroll(scrollBox); const nextScrollButton = findScrollButton(container, ariaLabelNextBtn); @@ -327,13 +386,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 +399,7 @@ describe('Tabs', () => { writable: true, }); - rerender(renderComponent({})); + if (scrollBox) fireEvent.scroll(scrollBox); const prevScrollButton = findScrollButton(container, ariaLabelPrevBtn); diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index 1f60a0fd8..15c0eb1da 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -187,17 +187,19 @@ export function TabsRender( updateVerticalOverflow(); }; - const hasHScroll = - isHorizontal && - tabListRef.current && - scrollBoxRef.current && - tabListRef.current?.clientWidth > scrollBoxRef.current?.clientWidth; - - const hasVScroll = - !isHorizontal && - tabListRef.current && - scrollBoxRef.current && - tabListRef.current.clientHeight > scrollBoxRef.current.clientHeight; + const hasHorizontalOverflow = + isHorizontal && (horizontalOverflow.start || horizontalOverflow.end); + + const hasVerticalOverflow = + !isHorizontal && (verticalOverflow.start || verticalOverflow.end); + + 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 = ( @@ -285,13 +287,14 @@ export function TabsRender( useEffect(() => { if (!isNotNil(selectedItemIdx)) return; + updateScrollState(); + if (isMounted) { debouncedScrollCorrection(orientation); } else { - updateScrollState(); setIsMounted(true); } - }, [selectedItemIdx, isMounted]); + }, [selectedItemIdx, isMounted, orientation]); const tabsProps = mergeProps({ className: s.base }, slotProps?.tabs); @@ -309,10 +312,10 @@ export function TabsRender( ref: scrollBoxRef, className: s.scrollBox, onScroll: updateScrollState, - 'data-overflow-inline-start': horizontalOverflow.start || undefined, - 'data-overflow-inline-end': horizontalOverflow.end || undefined, - 'data-overflow-block-start': verticalOverflow.start || undefined, - 'data-overflow-block-end': verticalOverflow.end || undefined, + '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 ); @@ -326,8 +329,8 @@ export function TabsRender( 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, isStretched && s.stretched, @@ -338,7 +341,7 @@ export function TabsRender( >
- {hasHScroll && ( + {hasHorizontalOverflow && ( <> Date: Wed, 1 Jul 2026 10:48:26 +0300 Subject: [PATCH 08/37] fix(Tabs): improve CSS-layout for the add-button --- .../src/components/Tabs/Tabs.stories.tsx | 5 +++++ .../TabAddButton/TabAddButton.module.css | 16 +--------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 709ac43ff..878ad9afc 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -102,6 +102,7 @@ export const Editable: Story = { render: function Render() { const [isVertical, { set: setVertical }] = useBoolean(false); const [isUnderlined, { set: setUnderlined }] = useBoolean(false); + const [isStretched, { set: setStretched }] = useBoolean(false); const list = useListData({ initialItems: [ @@ -150,12 +151,16 @@ export const Editable: Story = { Underlined + + Stretched + Date: Wed, 1 Jul 2026 10:56:38 +0300 Subject: [PATCH 09/37] fix(Tab): fix css-layout for onlyIcon state (round 2) --- .../Tabs/components/Tab/Tab.module.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index 4e9835c73..047f92c9b 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -76,14 +76,6 @@ z-index: calc(var(--kbq-layer-absolute) + 1); } - &:first-child { - border-inline-start: 0; - } - - &:last-child { - border-inline-end: 0; - } - &[data-selected]::after { background-color: var(--kbq-line-contrast); } @@ -98,6 +90,14 @@ border-inline: var(--kbq-size-xs) solid transparent; } + &:first-child { + border-inline-start: 0; + } + + &:last-child { + border-inline-end: 0; + } + .content { padding-inline: var(--tab-content-offset); margin-inline: calc(-1 * var(--tab-content-offset)); From c06f3c6f9714da606227a52c03def53307dc5f51 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 12:31:56 +0300 Subject: [PATCH 10/37] fix(TabScrollButton): improve css-layout --- .../Tabs/components/TabScrollButton/TabScrollButton.module.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 e8607e3cc..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,11 +1,10 @@ .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); &.prev { From 6e2e256208567b9518c7f1ab9ec03eb2e6e3db58 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 12:39:34 +0300 Subject: [PATCH 11/37] fix(TabAddButton): improve css-layout --- .../TabAddButton/TabAddButton.module.css | 36 +++++++------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css index f0e882787..1a5486bae 100644 --- a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css +++ b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css @@ -12,6 +12,14 @@ 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); } @@ -22,36 +30,16 @@ } } -.default { - &[data-hovered] .content { - background-color: var(--kbq-states-background-transparent-hover); - } - - &[data-pressed] .content { - background-color: var(--kbq-states-background-transparent-active); - } -} - .underlined { - --tab-content-offset: 0px; - - min-inline-size: 32px; - box-sizing: content-box; padding-block: var(--kbq-size-s); - - &[data-hovered]::after { - background-color: var(--kbq-line-contrast-fade); - } - - .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))); - } } .vertical { inline-size: 100%; + + .content { + background-color: var(--kbq-states-background-transparent-active); + } } .content { From 7f38994b7b9f57362ef6450bc6d05caa4f63ea73 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 12:49:10 +0300 Subject: [PATCH 12/37] docs(Tabs): improve Editable story --- packages/components/src/components/Tabs/Tabs.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 878ad9afc..bb24877ae 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -103,6 +103,7 @@ export const Editable: Story = { 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: [ @@ -154,6 +155,9 @@ export const Editable: Story = { Stretched + + Disabled + Date: Wed, 1 Jul 2026 13:10:03 +0300 Subject: [PATCH 13/37] fix(Tabs): disable add button with tabs --- .../components/src/components/Tabs/Tabs.test.tsx | 14 ++++++++++++++ packages/components/src/components/Tabs/Tabs.tsx | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index 7987fc9df..b9ef2be60 100644 --- a/packages/components/src/components/Tabs/Tabs.test.tsx +++ b/packages/components/src/components/Tabs/Tabs.test.tsx @@ -493,4 +493,18 @@ describe('Tabs editable', () => { 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 15c0eb1da..a595c6632 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -40,6 +40,7 @@ export function TabsRender( 'data-testid': dataTestId, isUnderlined = false, isStretched: isStretchedProp = false, + isDisabled = false, onRemove, onAdd, style, @@ -74,6 +75,10 @@ export function TabsRender( 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; @@ -386,6 +391,7 @@ export function TabsRender( {...slotProps?.addButton} orientation={orientation} isUnderlined={isUnderlined} + isDisabled={isAddButtonDisabled} /> )}
From a826890e1f07daa52bb663c2f9d572807c214a14 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 13:36:01 +0300 Subject: [PATCH 14/37] refactor(Tab): simplify a close-button --- .../src/components/Tabs/Tabs.test.tsx | 8 +++-- .../Tabs/components/Tab/Tab.module.css | 21 ++++++----- .../components/Tabs/components/Tab/Tab.tsx | 36 +++++++++---------- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index b9ef2be60..af72a3a96 100644 --- a/packages/components/src/components/Tabs/Tabs.test.tsx +++ b/packages/components/src/components/Tabs/Tabs.test.tsx @@ -354,9 +354,13 @@ describe('Tabs', () => { describe('scroll buttons behavior', () => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { - cb(0); + const id = window.setTimeout(() => cb(performance.now()), 0); - return 0; + return id; + }); + + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + window.clearTimeout(id); }); vi.spyOn(global.Math, 'min').mockReturnValue(1); diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index 047f92c9b..818138828 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -41,6 +41,16 @@ transparent calc(100% - var(--mask-size)) ); } + + .closeButton { + opacity: 0; + position: absolute; + visibility: hidden; + inset-block-start: 50%; + inset-inline-end: var(--kbq-size-m); + transform: translateY(-50%); + z-index: calc(var(--kbq-layer-absolute) + 1); + } } .default { @@ -185,14 +195,3 @@ display: flex; align-items: center; } - -.closeButton { - display: flex; - opacity: 0; - position: absolute; - visibility: hidden; - inset-block-start: 50%; - inset-inline-end: var(--kbq-size-m); - transform: translateY(-50%); - z-index: calc(var(--kbq-layer-absolute) + 1); -} diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.tsx b/packages/components/src/components/Tabs/components/Tab/Tab.tsx index 5ebbee309..29264490b 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.tsx +++ b/packages/components/src/components/Tabs/components/Tab/Tab.tsx @@ -82,6 +82,9 @@ export function Tab({ const endAddonProps = mergeProps({ className: s.addon }, slotProps?.endAddon); + const { className: closeButtonClassName, ...closeButtonRestProps } = + closeButtonProps ?? {}; + const Tag: ElementType = href ? 'a' : 'div'; const onKeyDown = (event: KeyboardEvent) => { @@ -130,25 +133,22 @@ export function Tab({
{isRemovable && ( - event.stopPropagation()} + onRemove?.()} + className={clsx(s.closeButton, closeButtonClassName)} + isCompact + preventFocusOnPress + {...closeButtonRestProps} > - onRemove?.()} - {...closeButtonProps} - > - - - + + )}
); From 235f55fbfccf6a6b64d6aaa3295b91220ed9923e Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 14:17:39 +0300 Subject: [PATCH 15/37] fix(Tab): change data-closable to data-allows-removing --- packages/components/src/components/Tabs/Tabs.module.css | 6 +++--- .../src/components/Tabs/components/Tab/Tab.module.css | 4 ++-- .../components/src/components/Tabs/components/Tab/Tab.tsx | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index 902f31e47..f97734964 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -18,7 +18,7 @@ .scrollBox { --mask-size: 32px; - &[data-overflow-inline-start='true'] { + &[data-overflow-inline-start] { mask-image: linear-gradient( to right, transparent var(--mask-size), @@ -26,7 +26,7 @@ ); } - &[data-overflow-inline-end='true'] { + &[data-overflow-inline-end] { mask-image: linear-gradient( to right, #000 calc(100% - var(--mask-size) * 1.5), @@ -34,7 +34,7 @@ ); } - &[data-overflow-inline-start='true'][data-overflow-inline-end='true'] { + &[data-overflow-inline-start][data-overflow-inline-end] { mask-image: linear-gradient( to right, transparent var(--mask-size), diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index 818138828..d89f15808 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -31,8 +31,8 @@ visibility: visible; } - &[data-closable][data-hovered] .inner, - &[data-closable][data-focus-visible] .inner { + &[data-allows-removing][data-hovered] .inner, + &[data-allows-removing][data-focus-visible] .inner { --mask-size: var(--kbq-size-l); mask-image: linear-gradient( diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.tsx b/packages/components/src/components/Tabs/components/Tab/Tab.tsx index 29264490b..64bb911e9 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.tsx +++ b/packages/components/src/components/Tabs/components/Tab/Tab.tsx @@ -47,7 +47,7 @@ export function Tab({ const { key, rendered } = item; const t = useLocalizedStringFormatter(intlMessages); - const isRemovable = !!onRemove; + const allowsRemoving = !!onRemove; const domRef = useDOMRef(innerRef); const { tabProps, isSelected, isDisabled } = useTab({ key }, state, domRef); @@ -88,7 +88,7 @@ export function Tab({ const Tag: ElementType = href ? 'a' : 'div'; const onKeyDown = (event: KeyboardEvent) => { - if (!isRemovable || isDisabled) return; + if (!allowsRemoving || isDisabled) return; if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); @@ -114,7 +114,7 @@ export function Tab({ data-disabled={isDisabled || undefined} data-selected={isSelected || undefined} data-onlyicon={onlyIcon || undefined} - data-closable={isRemovable || undefined} + data-allows-removing={allowsRemoving || undefined} data-underlined={isUnderlined || undefined} data-focus-visible={isFocusVisible || undefined} {...mergeProps(hoverProps, focusProps, tabProps, { @@ -132,7 +132,7 @@ export function Tab({ {isNotNil(endAddon) &&
{endAddon}
}
- {isRemovable && ( + {allowsRemoving && ( Date: Wed, 1 Jul 2026 16:50:29 +0300 Subject: [PATCH 16/37] fix(Tab): simplify css-layout for underlined tabs --- .../components/Tabs/components/Tab/Tab.module.css | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index d89f15808..91a445246 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -68,11 +68,9 @@ } .underlined { - --tab-content-offset: var(--kbq-size-m); - - box-sizing: content-box; padding-block: var(--kbq-size-s); - border-inline: var(--tab-content-offset) solid transparent; + box-sizing: content-box; + border-inline: var(--kbq-size-m) solid transparent; &::after { content: ''; @@ -95,8 +93,6 @@ } &.onlyIcon { - --tab-content-offset: 0px; - border-inline: var(--kbq-size-xs) solid transparent; } @@ -109,9 +105,7 @@ } .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))); + padding: 0; } .closeButton { From e3bc5ebb1e1f7632fe40f8dd2d7b560b5a462e13 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 17:01:54 +0300 Subject: [PATCH 17/37] fix(Tabs): show scroll mask in vertical orientation while scrolling --- .../src/components/Tabs/Tabs.module.css | 22 ++++++------------- .../src/components/Tabs/Tabs.stories.tsx | 7 +----- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index f97734964..04a2199ab 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -90,23 +90,11 @@ } .scrollBox { - display: inline-block; - inline-size: auto; - scrollbar-width: auto; - overflow: hidden auto; - } - - .tabList { - flex-direction: column; - } - - &[data-editable] .scrollBox { --mask-size: var(--kbq-size-3xl); - display: block; - inline-size: 100%; - block-size: 100%; - scrollbar-width: none; + inline-size: auto; + display: inline-block; + overflow: hidden auto; &[data-overflow-block-start][data-overflow-block-end] { mask-image: linear-gradient( @@ -134,6 +122,10 @@ ); } } + + .tabList { + flex-direction: column; + } } .stretched { diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index bb24877ae..f31b3b7ae 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -176,12 +176,7 @@ export const Editable: Story = { onRemove={removeTabs} > {(item) => ( - } - endAddon={} - key={item.id} - title={item.title} - > + } key={item.id} title={item.title}> {item.title} content )} From 015eee14034746e6140485c882b03620f776456d Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 17:36:39 +0300 Subject: [PATCH 18/37] refactor(Tabs): derive scroll buttons from overflow state --- .../components/src/components/Tabs/Tabs.tsx | 48 ++----------------- 1 file changed, 5 insertions(+), 43 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index a595c6632..825f604a7 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -55,11 +55,6 @@ export function TabsRender( const [isMounted, setIsMounted] = useState(false); - const [scrollButtonsVisibility, setScrollButtonsVisibility] = useState({ - prev: false, - next: false, - }); - const [horizontalOverflow, setHorizontalOverflow] = useState({ start: false, end: false, @@ -119,38 +114,6 @@ export function TabsRender( } }; - /** Syncs prev/next button visibility with current scroll position. */ - const updateScrollButtonsVisibility = () => { - if (!isHorizontal) return; - - if (!isNotNil(selectedItemIdx)) return; - - if (!itemsRefs[selectedItemIdx]) return; - - const { scrollBoxMeta, tabsListMeta } = getTabsMeta( - tabListRef, - scrollBoxRef, - itemsRefs[selectedItemIdx] - ); - - if (!scrollBoxMeta || !tabsListMeta) return; - - const { scrollLeft } = scrollBoxMeta; - - const isPrevButtonActive = scrollLeft > 0; - const isNextButtonActive = tabsListMeta.right - scrollBoxMeta.right > 1; - - setScrollButtonsVisibility((prevState) => { - const { prev, next } = prevState; - - if (isPrevButtonActive !== prev || isNextButtonActive !== next) { - return { prev: isPrevButtonActive, next: isNextButtonActive }; - } - - return prevState; - }); - }; - /** Syncs the horizontal fade mask with the current scroll position. */ const updateHorizontalOverflow = () => { if (!isHorizontal) return; @@ -185,9 +148,8 @@ export function TabsRender( ); }; - /** Syncs both scroll affordances: horizontal buttons and horizontal/vertical fade. */ + /** Syncs horizontal/vertical overflow affordances with the current scroll position. */ const updateScrollState = () => { - updateScrollButtonsVisibility(); updateHorizontalOverflow(); updateVerticalOverflow(); }; @@ -258,7 +220,7 @@ export function TabsRender( } }; - const [debouncedUpdateScrollButtonsActivity] = useDebounceCallback({ + const [debouncedUpdateScrollState] = useDebounceCallback({ callback: updateScrollState, delay: 100, }); @@ -270,7 +232,7 @@ export function TabsRender( useResizeObserverRefs( useMemo(() => [scrollBoxRef], [scrollBoxRef]), - () => debouncedUpdateScrollButtonsActivity() + () => debouncedUpdateScrollState() ); const scrollPrev = () => { @@ -352,13 +314,13 @@ export function TabsRender( onPress={scrollPrev} ref={scrollButtonRef} aria-label={t.format('prev')} - isInvisible={!scrollButtonsVisibility.prev} + isInvisible={!activeHorizontalOverflow.start} /> )} From fea73c0fb4c9c59de2bfa93b747e8c8c9c903a64 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 18:16:48 +0300 Subject: [PATCH 19/37] fix(Tabs): skip scroll correction without overflow --- .../src/components/Tabs/Tabs.test.tsx | 89 ++++++++++++++++++- .../components/src/components/Tabs/Tabs.tsx | 10 ++- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index af72a3a96..1d17e33ac 100644 --- a/packages/components/src/components/Tabs/Tabs.test.tsx +++ b/packages/components/src/components/Tabs/Tabs.test.tsx @@ -309,10 +309,10 @@ describe('Tabs', () => { vi.useFakeTimers(); try { - const { container } = render( + const { container, rerender } = render( renderComponent({ orientation: 'vertical', - selectedKey: '4', + selectedKey: '1', }) ); @@ -320,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, @@ -335,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, @@ -346,7 +429,7 @@ describe('Tabs', () => { await vi.advanceTimersByTimeAsync(100); }); - expect(scrollBox.scrollTop).toBe(60); + expect(scrollBox.scrollTop).toBe(0); } finally { vi.useRealTimers(); } diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index 825f604a7..5517a86f0 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -174,6 +174,8 @@ export function TabsRender( itemIdx = selectedItemIdx, behavior: ScrollBehavior = 'smooth' ) => { + if (!hasHorizontalOverflow && !hasVerticalOverflow) return; + if (!isNotNil(itemIdx)) return; const selectedEl = itemsRefs[itemIdx]; @@ -256,11 +258,13 @@ export function TabsRender( updateScrollState(); - if (isMounted) { - debouncedScrollCorrection(orientation); - } else { + if (!isMounted) { setIsMounted(true); + + return; } + + debouncedScrollCorrection(orientation); }, [selectedItemIdx, isMounted, orientation]); const tabsProps = mergeProps({ className: s.base }, slotProps?.tabs); From f9c63b5866dc7ecbe2c6402ce00affa589617225 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 19:20:58 +0300 Subject: [PATCH 20/37] fix(Tabs): align add button with tab list in vertical orientation --- packages/components/src/components/Tabs/Tabs.module.css | 5 +++-- packages/components/src/components/Tabs/Tabs.stories.tsx | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index 04a2199ab..a1c95b4ff 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -82,17 +82,18 @@ .base { flex-direction: column; min-block-size: 0; + flex-shrink: 0; } .scrollArea { - flex: 1 1 auto; + inline-size: 100%; min-block-size: 0; } .scrollBox { --mask-size: var(--kbq-size-3xl); - inline-size: auto; + inline-size: 100%; display: inline-block; overflow: hidden auto; diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index f31b3b7ae..8f7bd732b 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -167,11 +167,17 @@ export const Editable: Story = { isStretched={isStretched} isDisabled={isDisabled} selectedKey={selectedKey} - disabledKeys={['hips']} onSelectionChange={setSelectedKey} style={{ blockSize: isVertical && !isUnderlined ? 280 : undefined, }} + slotProps={{ + tabs: { + style: { + inlineSize: isVertical && !isUnderlined ? 240 : undefined, + }, + }, + }} onAdd={addTab} onRemove={removeTabs} > From 800f5bafe5a4abc7e86ea48e4a10673ff208ae34 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 20:30:50 +0300 Subject: [PATCH 21/37] fix(Tab): restore underlined tab styles --- .../components/Tabs/components/Tab/Tab.module.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index 91a445246..d89f15808 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -68,9 +68,11 @@ } .underlined { - padding-block: var(--kbq-size-s); + --tab-content-offset: var(--kbq-size-m); + box-sizing: content-box; - border-inline: var(--kbq-size-m) solid transparent; + padding-block: var(--kbq-size-s); + border-inline: var(--tab-content-offset) solid transparent; &::after { content: ''; @@ -93,6 +95,8 @@ } &.onlyIcon { + --tab-content-offset: 0px; + border-inline: var(--kbq-size-xs) solid transparent; } @@ -105,7 +109,9 @@ } .content { - padding: 0; + padding-inline: var(--tab-content-offset); + margin-inline: calc(-1 * var(--tab-content-offset)); + inline-size: calc(100% + calc(2 * var(--tab-content-offset))); } .closeButton { From d2f3b39c7b059bbdff421574e08c3863227fb79d Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 20:31:06 +0300 Subject: [PATCH 22/37] fix(Tabs): prevent false scroll in underlined tabs --- packages/components/src/components/Tabs/Tabs.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index a1c95b4ff..e028b1fd8 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -74,6 +74,7 @@ } .tabList { + overflow: clip; flex-direction: row; } } From 902240dbd30c38b5233720d11b0b59b70bfd7bed Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 1 Jul 2026 20:44:52 +0300 Subject: [PATCH 23/37] docs(Tabs): simplify editable tabs description --- packages/components/src/components/Tabs/Tabs.mdx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.mdx b/packages/components/src/components/Tabs/Tabs.mdx index 7fa5177a6..f8876a451 100644 --- a/packages/components/src/components/Tabs/Tabs.mdx +++ b/packages/components/src/components/Tabs/Tabs.mdx @@ -42,16 +42,10 @@ You can render tabs dynamically by using `items` prop. ## Editable -Provide `onRemove` to let users delete tabs and `onAdd` to let them create new ones. -`Tabs` doesn't own the collection — you manage it (for example with `useListData`) and -`Tabs` just calls these handlers: - -- `onRemove(keys: Set)` follows the React Aria removable-collection convention - (`onRemove={(keys) => list.remove(...keys)}`). When provided, a close (×) button appears - on every tab; tabs can also be removed with `Delete` / `Backspace`. -- `onAdd()` renders a trailing add ("+") button. In horizontal orientation it stays pinned - to the right while tabs scroll; in vertical orientation it becomes a full-width button - pinned to the bottom, and the scrollable area fades out at the edges that overflow. +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. From b0c7285e5d7b290245809b010241bce4da51a69d Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 12:47:16 +0300 Subject: [PATCH 24/37] fix(Tabs): add padding for add button in vertical orientation --- .../components/src/components/Tabs/Tabs.module.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index e028b1fd8..bbcc4fe61 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -128,6 +128,16 @@ .tabList { flex-direction: column; } + + [data-slot='add-button'] { + padding-block-start: var(--kbq-size-s); + } + + &:has(.tabList:empty) { + [data-slot='add-button'] { + padding-block-start: 0; + } + } } .stretched { From 48e014020f9be75e630fac0d8e98cd4ca44790c5 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 12:48:30 +0300 Subject: [PATCH 25/37] refactor(Tabs): add `overflow: clip` for underlined tabs --- packages/components/src/components/Tabs/Tabs.module.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index bbcc4fe61..76bac9e50 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -61,6 +61,10 @@ background: var(--kbq-line-contrast-less); z-index: calc(var(--kbq-layer-absolute) + 3); } + + .tabList { + overflow: clip; + } } .horizontal { @@ -74,7 +78,6 @@ } .tabList { - overflow: clip; flex-direction: row; } } From 865274b8d90207e9bb7423d7f7d57dde6af1f34e Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 12:52:32 +0300 Subject: [PATCH 26/37] fix(Tabs): restore disabled state for underlined tabs --- .../src/components/Tabs/components/Tab/Tab.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index d89f15808..1c6fd3a73 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -94,6 +94,10 @@ background-color: var(--kbq-line-contrast-fade); } + &[data-disabled]::after { + background-color: var(--kbq-states-line-disabled); + } + &.onlyIcon { --tab-content-offset: 0px; From bc8c99760454355bf7723b718efde842a16e3655 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 13:15:32 +0300 Subject: [PATCH 27/37] docs(Tabs): add EditablePlayground story --- .../components/src/components/Tabs/Tabs.mdx | 2 + .../src/components/Tabs/Tabs.stories.tsx | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/components/src/components/Tabs/Tabs.mdx b/packages/components/src/components/Tabs/Tabs.mdx index f8876a451..9e8b64a2b 100644 --- a/packages/components/src/components/Tabs/Tabs.mdx +++ b/packages/components/src/components/Tabs/Tabs.mdx @@ -47,6 +47,8 @@ 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 diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 8f7bd732b..66d61eee7 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -99,6 +99,65 @@ export const Dynamic: Story = { }; export const Editable: Story = { + render: function Render() { + 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 ( + + {(item) => ( + } key={item.id} title={item.title}> + {item.title} content + + )} + + ); + }, +}; + +export const EditablePlayground: Story = { render: function Render() { const [isVertical, { set: setVertical }] = useBoolean(false); const [isUnderlined, { set: setUnderlined }] = useBoolean(false); From 6ab1229c1f0bdafe14d82aa454216565322b82b0 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 13:25:29 +0300 Subject: [PATCH 28/37] docs(Tabs): improve Editable story --- .../src/components/Tabs/Tabs.stories.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 66d61eee7..0a322ffb5 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -100,16 +100,19 @@ 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' }, - { 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' }, + { 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 }, ], }); @@ -134,7 +137,7 @@ export const Editable: Story = { counter.current += 1; const id = `new-${counter.current}`; - list.append({ id, title: `New tab ${counter.current}` }); + list.append({ id, title: `New tab ${counter.current}`, content }); setSelectedKey(id); }; @@ -149,7 +152,7 @@ export const Editable: Story = { > {(item) => ( } key={item.id} title={item.title}> - {item.title} content + {item.content} )} From 0c7492bda26bdc7a20652e9a945792a1edd22cdc Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 13:27:51 +0300 Subject: [PATCH 29/37] chore(Tabs): approve api --- tools/public_api_guard/components/Tabs.api.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tools/public_api_guard/components/Tabs.api.md b/tools/public_api_guard/components/Tabs.api.md index 85bb88f21..6b6a5b4f7 100644 --- a/tools/public_api_guard/components/Tabs.api.md +++ b/tools/public_api_guard/components/Tabs.api.md @@ -5,12 +5,17 @@ ```ts import type { AriaTabListProps } from '@koobiq/react-primitives'; +import { ButtonBaseProps } 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 +58,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 +79,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) ``` From 43076caf885b0031c9aa5774d54de39697fae11c Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 13:53:51 +0300 Subject: [PATCH 30/37] fix(Tabs): fix css-layout for underlined tabs --- .../src/components/Tabs/components/Tab/Tab.module.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index 1c6fd3a73..3a483ea49 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -108,10 +108,6 @@ border-inline-start: 0; } - &:last-child { - border-inline-end: 0; - } - .content { padding-inline: var(--tab-content-offset); margin-inline: calc(-1 * var(--tab-content-offset)); From 43e7a2191d974bbf1b7eb628f7cc929e4157e24c Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 19:13:04 +0300 Subject: [PATCH 31/37] fix(Tabs): code review --- .../src/components/Tabs/Tabs.stories.tsx | 5 ----- .../components/Tabs/components/Tab/Tab.module.css | 15 ++++++--------- .../TabAddButton/TabAddButton.module.css | 5 +---- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 0a322ffb5..4bc3177ce 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -566,7 +566,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Email" name="email" - placeholder="Enter your email" type="email" autoComplete="email" /> @@ -574,7 +573,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Password" name="password" - placeholder="Enter your password" type="password" autoComplete="current-password" /> @@ -593,7 +591,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Name" name="name" - placeholder="Enter your name" type="text" autoComplete="name" /> @@ -601,7 +598,6 @@ export const WithForm: Story = { {...requiredFieldProps} label="Email" name="email" - placeholder="Enter your email" type="email" autoComplete="email" /> @@ -609,7 +605,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/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index 3a483ea49..3e125be35 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -86,15 +86,15 @@ z-index: calc(var(--kbq-layer-absolute) + 1); } - &[data-selected]::after { - background-color: var(--kbq-line-contrast); - } - &[data-hovered]::after { background-color: var(--kbq-line-contrast-fade); } - &[data-disabled]::after { + &[data-selected]::after { + background-color: var(--kbq-line-contrast); + } + + &[data-disabled][data-selected]::after { background-color: var(--kbq-states-line-disabled); } @@ -173,10 +173,7 @@ 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); + transition: outline-color var(--kbq-transition-default); } .inner { diff --git a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css index 1a5486bae..8e78065b2 100644 --- a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css +++ b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.module.css @@ -56,8 +56,5 @@ 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); + transition: outline-color var(--kbq-transition-default); } From 64bd06c4b3aedc02b21200871bf0677c5be00534 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 20:21:29 +0300 Subject: [PATCH 32/37] fix(TabAddButton): extend TabAddButtonProps type --- .claude/launch.json | 11 +++++++++++ .../Tabs/components/TabAddButton/TabAddButton.tsx | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..712092b62 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "storybook", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["exec", "storybook", "dev", "-p", "6007", "--ci"], + "port": 6007 + } + ] +} diff --git a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx index 9cf67a3ae..5aa51d472 100644 --- a/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx +++ b/packages/components/src/components/Tabs/components/TabAddButton/TabAddButton.tsx @@ -4,14 +4,14 @@ import { clsx } from '@koobiq/react-core'; import { IconPlus16 } from '@koobiq/react-icons'; import { Button as ButtonPrimitive, - type ButtonBaseProps, + type ButtonProps, } from '@koobiq/react-primitives'; import s from './TabAddButton.module.css'; type TabAddButtonOrientation = 'horizontal' | 'vertical'; -export type TabAddButtonProps = Omit & { +export type TabAddButtonProps = Omit & { className?: string; }; From 60ffcb7ca99f5b1f00686f398b2b7e2fedb61078 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 2 Jul 2026 20:26:43 +0300 Subject: [PATCH 33/37] chore(Tabs): approve api --- tools/public_api_guard/components/Tabs.api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/public_api_guard/components/Tabs.api.md b/tools/public_api_guard/components/Tabs.api.md index 6b6a5b4f7..2e45093c7 100644 --- a/tools/public_api_guard/components/Tabs.api.md +++ b/tools/public_api_guard/components/Tabs.api.md @@ -5,7 +5,8 @@ ```ts import type { AriaTabListProps } from '@koobiq/react-primitives'; -import { ButtonBaseProps } 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'; From ab15ef50d3cb63db0dff3271ceb7f5ddaf961f39 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 10:15:49 +0300 Subject: [PATCH 34/37] docs(Tabs): add Keyboard Activation story --- .../components/src/components/Tabs/Tabs.mdx | 7 +++++ .../src/components/Tabs/Tabs.stories.tsx | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/components/src/components/Tabs/Tabs.mdx b/packages/components/src/components/Tabs/Tabs.mdx index 9e8b64a2b..948e7b6d6 100644 --- a/packages/components/src/components/Tabs/Tabs.mdx +++ b/packages/components/src/components/Tabs/Tabs.mdx @@ -115,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.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx index 4bc3177ce..2d868f4a3 100644 --- a/packages/components/src/components/Tabs/Tabs.stories.tsx +++ b/packages/components/src/components/Tabs/Tabs.stories.tsx @@ -517,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 ( From fd5a6445dbf87cacfc451e462117ac30c5bb8b59 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 10:31:54 +0300 Subject: [PATCH 35/37] fix(Tabs): keep selected tab close button visible --- .../src/components/Tabs/components/Tab/Tab.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.module.css b/packages/components/src/components/Tabs/components/Tab/Tab.module.css index 3e125be35..4005aa8e5 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.module.css +++ b/packages/components/src/components/Tabs/components/Tab/Tab.module.css @@ -25,12 +25,14 @@ cursor: default; } + &[data-selected] .closeButton, &[data-hovered] .closeButton, &[data-focus-visible] .closeButton { opacity: 1; visibility: visible; } + &[data-allows-removing][data-selected] .inner, &[data-allows-removing][data-hovered] .inner, &[data-allows-removing][data-focus-visible] .inner { --mask-size: var(--kbq-size-l); From 7379f0c4d4b491f7337b34de8c0addf1e978d070 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 12:42:29 +0300 Subject: [PATCH 36/37] feat(Tabs): improve vertical scroll styling --- packages/components/src/components/Tabs/Tabs.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index 76bac9e50..78229a3c9 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -100,6 +100,10 @@ inline-size: 100%; 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( From 7a8d1b3d13ed1192875b0adb92a53860263f3744 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 3 Jul 2026 14:50:50 +0300 Subject: [PATCH 37/37] chore: remove .claude/launch.json --- .claude/launch.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 712092b62..000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "storybook", - "runtimeExecutable": "pnpm", - "runtimeArgs": ["exec", "storybook", "dev", "-p", "6007", "--ci"], - "port": 6007 - } - ] -}