From 7f256b15e738a6c1bbd330e1ae4dc214c130adc1 Mon Sep 17 00:00:00 2001 From: Aleksei Berezkin <16083785+aleksei-berezkin@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:06:56 +0200 Subject: [PATCH 01/36] RG-2786 Ring UI 8 - version bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a1b470542a..b9c811cd00e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jetbrains/ring-ui", - "version": "7.0.114", + "version": "8.0.0-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@jetbrains/ring-ui", - "version": "7.0.114", + "version": "8.0.0-beta.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index bc4b704f31e..55d5faf95c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jetbrains/ring-ui", - "version": "7.0.114", + "version": "8.0.0-beta.0", "description": "JetBrains UI library", "author": { "name": "JetBrains" From 51285210d7eefc79105930ae8a3c1d2fefb34d22 Mon Sep 17 00:00:00 2001 From: Aleksei Berezkin <16083785+aleksei-berezkin@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:15:36 +0200 Subject: [PATCH 02/36] RG-2542 table -> legacy-table (#9306) --- CHANGELOG.md | 5 +++++ src/data-list/data-list.mock.tsx | 2 +- src/data-list/data-list.stories.tsx | 2 +- src/data-list/data-list.test.tsx | 2 +- src/data-list/data-list.tsx | 6 +++--- src/data-list/item.tsx | 2 +- src/data-list/selection.ts | 6 +++++- src/{table => legacy-table}/cell.tsx | 0 src/{table => legacy-table}/disable-hover-hoc.tsx | 0 src/{table => legacy-table}/header-cell.tsx | 0 src/{table => legacy-table}/header.tsx | 0 src/{table => legacy-table}/multitable.tsx | 0 src/{table => legacy-table}/row-with-focus-sensor.tsx | 0 src/{table => legacy-table}/row.tsx | 0 src/{table => legacy-table}/selection-adapter.ts | 0 src/{table => legacy-table}/selection-shortcuts-hoc.tsx | 0 src/{table => legacy-table}/selection.ts | 0 src/{table => legacy-table}/simple-table.stories.tsx | 2 +- src/{table => legacy-table}/simple-table.tsx | 0 src/{table => legacy-table}/smart-table.tsx | 0 src/{table => legacy-table}/table.css | 0 src/{table => legacy-table}/table.examples2.json | 0 src/{table => legacy-table}/table.stories.json | 0 src/{table => legacy-table}/table.stories.tsx | 2 +- src/{table => legacy-table}/table.tsx | 0 25 files changed, 19 insertions(+), 10 deletions(-) rename src/{table => legacy-table}/cell.tsx (100%) rename src/{table => legacy-table}/disable-hover-hoc.tsx (100%) rename src/{table => legacy-table}/header-cell.tsx (100%) rename src/{table => legacy-table}/header.tsx (100%) rename src/{table => legacy-table}/multitable.tsx (100%) rename src/{table => legacy-table}/row-with-focus-sensor.tsx (100%) rename src/{table => legacy-table}/row.tsx (100%) rename src/{table => legacy-table}/selection-adapter.ts (100%) rename src/{table => legacy-table}/selection-shortcuts-hoc.tsx (100%) rename src/{table => legacy-table}/selection.ts (100%) rename src/{table => legacy-table}/simple-table.stories.tsx (98%) rename src/{table => legacy-table}/simple-table.tsx (100%) rename src/{table => legacy-table}/smart-table.tsx (100%) rename src/{table => legacy-table}/table.css (100%) rename src/{table => legacy-table}/table.examples2.json (100%) rename src/{table => legacy-table}/table.stories.json (100%) rename src/{table => legacy-table}/table.stories.tsx (99%) rename src/{table => legacy-table}/table.tsx (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c23ec92e67c..065070ee17e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [8.0.0] + +### BREAKING CHANGES +- Introduced the new Table component in the existing `table` directory. The old tables and related files (such as `selection.ts`) were moved to the `legacy-table` directory. To continue using the old tables, update your imports (for example, `import SimpleTable from '@jetbrains/ring-ui/components/table/simple-table'` -> `import SimpleTable from '@jetbrains/ring-ui/components/legacy-table/simple-table'`). + ## [7.0.76] - Fixed the `jsx` files bundling in dist, which could cause imports resolving errors. diff --git a/src/data-list/data-list.mock.tsx b/src/data-list/data-list.mock.tsx index 3df0e65a2b9..6437c3b93e1 100644 --- a/src/data-list/data-list.mock.tsx +++ b/src/data-list/data-list.mock.tsx @@ -3,7 +3,7 @@ import {type ReactNode} from 'react'; import Link from '../link/link'; import Tag from '../tag/tag'; -import {type SelectionItem} from '../table/selection'; +import {type SelectionItem} from '../legacy-table/selection'; export interface Item extends SelectionItem { title: ReactNode; diff --git a/src/data-list/data-list.stories.tsx b/src/data-list/data-list.stories.tsx index 5b78b40799b..f4293b94356 100644 --- a/src/data-list/data-list.stories.tsx +++ b/src/data-list/data-list.stories.tsx @@ -5,7 +5,7 @@ import Selection from './selection'; import {type FormattedItem, moreLessButtonStates} from './item'; import mock, {type Item, moreItems} from './data-list.mock'; -import type TableSelection from '../table/selection'; +import type TableSelection from '../legacy-table/selection'; export default { title: 'Components/DataList', diff --git a/src/data-list/data-list.test.tsx b/src/data-list/data-list.test.tsx index 018f2a59f63..e9c000bd9df 100644 --- a/src/data-list/data-list.test.tsx +++ b/src/data-list/data-list.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react'; -import {type SelectionItem} from '../table/selection'; +import {type SelectionItem} from '../legacy-table/selection'; import DataList, {type DataListContainerProps} from './data-list'; import Selection from './selection'; diff --git a/src/data-list/data-list.tsx b/src/data-list/data-list.tsx index f2800e52f59..cd798ea5380 100644 --- a/src/data-list/data-list.tsx +++ b/src/data-list/data-list.tsx @@ -9,12 +9,12 @@ import focusSensorHOC, {type FocusSensorAddProps, type FocusSensorOuterProps} fr import selectionShortcutsHOC, { type SelectionShortcutsAddProps, type SelectionShortcutsOuterProps, -} from '../table/selection-shortcuts-hoc'; -import disableHoverHOC, {type DisableHoverAddProps} from '../table/disable-hover-hoc'; +} from '../legacy-table/selection-shortcuts-hoc'; +import disableHoverHOC, {type DisableHoverAddProps} from '../legacy-table/disable-hover-hoc'; import getUID from '../global/get-uid'; import Shortcuts from '../shortcuts/shortcuts'; import Loader from '../loader/loader'; -import {type SelectionItem} from '../table/selection'; +import {type SelectionItem} from '../legacy-table/selection'; import Item, {type FormattedItem, moreLessButtonStates} from './item'; import type Selection from './selection'; diff --git a/src/data-list/item.tsx b/src/data-list/item.tsx index 9d784c99138..b10c1ef16e1 100644 --- a/src/data-list/item.tsx +++ b/src/data-list/item.tsx @@ -6,7 +6,7 @@ import Link from '../link/link'; import Text from '../text/text'; import LoaderInline from '../loader-inline/loader-inline'; import Button from '../button/button'; -import {type SelectionItem} from '../table/selection'; +import {type SelectionItem} from '../legacy-table/selection'; import Title from './title'; import type Selection from './selection'; diff --git a/src/data-list/selection.ts b/src/data-list/selection.ts index 09b0a41ce3b..e8f3907a326 100644 --- a/src/data-list/selection.ts +++ b/src/data-list/selection.ts @@ -1,4 +1,8 @@ -import TableSelection, {type CloneWithConfig, type SelectionItem, type TableSelectionConfig} from '../table/selection'; +import TableSelection, { + type CloneWithConfig, + type SelectionItem, + type TableSelectionConfig, +} from '../legacy-table/selection'; interface DataListSelectionConfig extends TableSelectionConfig { partialSelected?: Set | undefined; diff --git a/src/table/cell.tsx b/src/legacy-table/cell.tsx similarity index 100% rename from src/table/cell.tsx rename to src/legacy-table/cell.tsx diff --git a/src/table/disable-hover-hoc.tsx b/src/legacy-table/disable-hover-hoc.tsx similarity index 100% rename from src/table/disable-hover-hoc.tsx rename to src/legacy-table/disable-hover-hoc.tsx diff --git a/src/table/header-cell.tsx b/src/legacy-table/header-cell.tsx similarity index 100% rename from src/table/header-cell.tsx rename to src/legacy-table/header-cell.tsx diff --git a/src/table/header.tsx b/src/legacy-table/header.tsx similarity index 100% rename from src/table/header.tsx rename to src/legacy-table/header.tsx diff --git a/src/table/multitable.tsx b/src/legacy-table/multitable.tsx similarity index 100% rename from src/table/multitable.tsx rename to src/legacy-table/multitable.tsx diff --git a/src/table/row-with-focus-sensor.tsx b/src/legacy-table/row-with-focus-sensor.tsx similarity index 100% rename from src/table/row-with-focus-sensor.tsx rename to src/legacy-table/row-with-focus-sensor.tsx diff --git a/src/table/row.tsx b/src/legacy-table/row.tsx similarity index 100% rename from src/table/row.tsx rename to src/legacy-table/row.tsx diff --git a/src/table/selection-adapter.ts b/src/legacy-table/selection-adapter.ts similarity index 100% rename from src/table/selection-adapter.ts rename to src/legacy-table/selection-adapter.ts diff --git a/src/table/selection-shortcuts-hoc.tsx b/src/legacy-table/selection-shortcuts-hoc.tsx similarity index 100% rename from src/table/selection-shortcuts-hoc.tsx rename to src/legacy-table/selection-shortcuts-hoc.tsx diff --git a/src/table/selection.ts b/src/legacy-table/selection.ts similarity index 100% rename from src/table/selection.ts rename to src/legacy-table/selection.ts diff --git a/src/table/simple-table.stories.tsx b/src/legacy-table/simple-table.stories.tsx similarity index 98% rename from src/table/simple-table.stories.tsx rename to src/legacy-table/simple-table.stories.tsx index 232a249815f..693e3a0fa5c 100644 --- a/src/table/simple-table.stories.tsx +++ b/src/legacy-table/simple-table.stories.tsx @@ -13,7 +13,7 @@ import tableData from './table.examples2.json'; * Simple stateless table without hover effect */ export default { - title: 'Components/Simple Table', + title: 'Components/Legacy Table/Simple Table', component: SimpleTable, parameters: { diff --git a/src/table/simple-table.tsx b/src/legacy-table/simple-table.tsx similarity index 100% rename from src/table/simple-table.tsx rename to src/legacy-table/simple-table.tsx diff --git a/src/table/smart-table.tsx b/src/legacy-table/smart-table.tsx similarity index 100% rename from src/table/smart-table.tsx rename to src/legacy-table/smart-table.tsx diff --git a/src/table/table.css b/src/legacy-table/table.css similarity index 100% rename from src/table/table.css rename to src/legacy-table/table.css diff --git a/src/table/table.examples2.json b/src/legacy-table/table.examples2.json similarity index 100% rename from src/table/table.examples2.json rename to src/legacy-table/table.examples2.json diff --git a/src/table/table.stories.json b/src/legacy-table/table.stories.json similarity index 100% rename from src/table/table.stories.json rename to src/legacy-table/table.stories.json diff --git a/src/table/table.stories.tsx b/src/legacy-table/table.stories.tsx similarity index 99% rename from src/table/table.stories.tsx rename to src/legacy-table/table.stories.tsx index b4cf8c05019..b61db0db3ae 100644 --- a/src/table/table.stories.tsx +++ b/src/legacy-table/table.stories.tsx @@ -14,7 +14,7 @@ import mock from './table.stories.json'; import tableData from './table.examples2.json'; export default { - title: 'Components/Table', + title: 'Components/Legacy Table/Table', component: BaseTable, parameters: { diff --git a/src/table/table.tsx b/src/legacy-table/table.tsx similarity index 100% rename from src/table/table.tsx rename to src/legacy-table/table.tsx From 24c58a519ad0b96c8024b54557ef33dffa9606a4 Mon Sep 17 00:00:00 2001 From: Aleksei Berezkin <16083785+aleksei-berezkin@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:42:05 +0200 Subject: [PATCH 03/36] RG-2542 Renamed a CSS file to avoid classnames clash --- src/legacy-table/cell.tsx | 2 +- src/legacy-table/header-cell.tsx | 2 +- src/legacy-table/header.tsx | 2 +- src/legacy-table/{table.css => legacy-table.css} | 0 src/legacy-table/row.tsx | 2 +- src/legacy-table/simple-table.tsx | 2 +- src/legacy-table/table.tsx | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename src/legacy-table/{table.css => legacy-table.css} (100%) diff --git a/src/legacy-table/cell.tsx b/src/legacy-table/cell.tsx index 1b0073b6a3c..d3ee2122dd0 100644 --- a/src/legacy-table/cell.tsx +++ b/src/legacy-table/cell.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import dataTests from '../global/data-tests'; -import style from './table.css'; +import style from './legacy-table.css'; export interface CellProps extends TdHTMLAttributes { 'data-test'?: string | null | undefined; diff --git a/src/legacy-table/header-cell.tsx b/src/legacy-table/header-cell.tsx index 68e0be80336..2acc51b8a62 100644 --- a/src/legacy-table/header-cell.tsx +++ b/src/legacy-table/header-cell.tsx @@ -6,7 +6,7 @@ import sortedIcon from '@jetbrains/icons/chevron-12px-down'; import Icon from '../icon/icon'; import dataTests from '../global/data-tests'; -import style from './table.css'; +import style from './legacy-table.css'; export interface Column { id: string; diff --git a/src/legacy-table/header.tsx b/src/legacy-table/header.tsx index f9ac48cf490..a2c7a2d5a2e 100644 --- a/src/legacy-table/header.tsx +++ b/src/legacy-table/header.tsx @@ -5,7 +5,7 @@ import Checkbox from '../checkbox/checkbox'; import getUID from '../global/get-uid'; import HeaderCell, {type Column, type SortParams} from './header-cell'; -import style from './table.css'; +import style from './legacy-table.css'; export interface HeaderProps { columns: readonly Column[]; diff --git a/src/legacy-table/table.css b/src/legacy-table/legacy-table.css similarity index 100% rename from src/legacy-table/table.css rename to src/legacy-table/legacy-table.css diff --git a/src/legacy-table/row.tsx b/src/legacy-table/row.tsx index d280055f014..2fafe260e34 100644 --- a/src/legacy-table/row.tsx +++ b/src/legacy-table/row.tsx @@ -15,7 +15,7 @@ import {type FocusSensorAddProps} from '../global/focus-sensor-hoc'; import Cell from './cell'; import {type Column} from './header-cell'; -import style from './table.css'; +import style from './legacy-table.css'; interface DragHandleProps { alwaysShowDragHandle: boolean; diff --git a/src/legacy-table/simple-table.tsx b/src/legacy-table/simple-table.tsx index fe221090474..e0f60d03343 100644 --- a/src/legacy-table/simple-table.tsx +++ b/src/legacy-table/simple-table.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import Table, {type TableAttrs} from './table'; import Selection, {type SelectionItem} from './selection'; -import style from './table.css'; +import style from './legacy-table.css'; export interface SimpleTableProps extends Omit< TableAttrs, diff --git a/src/legacy-table/table.tsx b/src/legacy-table/table.tsx index 41048f165f5..aef812ac1c7 100644 --- a/src/legacy-table/table.tsx +++ b/src/legacy-table/table.tsx @@ -21,7 +21,7 @@ import disableHoverHOC, {type DisableHoverAddProps, type DisableHoverProps} from import Row from './row-with-focus-sensor'; import {type Column, type SortParams} from './header-cell'; -import style from './table.css'; +import style from './legacy-table.css'; export interface ReorderParams { data: T[]; From 59a7833bf114d0031b3920946f792a876e49b683 Mon Sep 17 00:00:00 2001 From: Aleksei Berezkin <16083785+aleksei-berezkin@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:44:37 +0200 Subject: [PATCH 04/36] RG-2787 React 19.2 (#9310) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + babel.config.js | 2 +- package-lock.json | 8 +- package.json | 8 +- .../auth-dialog-service.tsx | 4 +- src/button-group/button-group.stories.tsx | 4 +- src/button/button.stories.tsx | 4 +- src/collapse/collapse-content.tsx | 4 +- src/collapse/collapse-control.tsx | 4 +- src/collapse/collapse.tsx | 4 +- src/confirm-service/confirm-service.tsx | 4 +- src/date-picker/use-scroll-behavior.ts | 11 +- src/dialog/dialog.tsx | 6 +- src/dropdown-menu/dropdown-menu.tsx | 23 ++- src/editable-heading/editable-heading.tsx | 11 +- src/expand/collapsible-group.tsx | 151 +++++++++--------- src/global/create-stateful-context.tsx | 10 +- src/global/rerender-hoc.tsx | 8 +- src/global/theme.tsx | 25 ++- src/global/use-event-callback.ts | 18 --- src/i18n/i18n-context.tsx | 2 +- src/input/input.stories.tsx | 4 +- src/island/adaptive-island-hoc.tsx | 8 +- src/island/content.tsx | 11 +- src/login-dialog/service.tsx | 4 +- src/popup/popup.target.tsx | 14 +- src/query-assist/query-assist.tsx | 4 +- src/radio/radio-item.tsx | 8 +- src/radio/radio.tsx | 2 +- src/select/select.test.tsx | 34 +++- src/slider/slider.tsx | 9 +- src/tab-trap/tab-trap.tsx | 36 ++--- src/tooltip/tooltip.tsx | 4 +- src/upload/upload.tsx | 28 +--- src/user-agreement/service.tsx | 4 +- 35 files changed, 233 insertions(+), 250 deletions(-) delete mode 100644 src/global/use-event-callback.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 065070ee17e..299106425bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### BREAKING CHANGES - Introduced the new Table component in the existing `table` directory. The old tables and related files (such as `selection.ts`) were moved to the `legacy-table` directory. To continue using the old tables, update your imports (for example, `import SimpleTable from '@jetbrains/ring-ui/components/table/simple-table'` -> `import SimpleTable from '@jetbrains/ring-ui/components/legacy-table/simple-table'`). +- Changed the minimum supported React version to 19.2.0. +- Removed the `useEventCallback()` custom hook; use the `useEffectEvent()` React hook instead. ## [7.0.76] - Fixed the `jsx` files bundling in dist, which could cause imports resolving errors. diff --git a/babel.config.js b/babel.config.js index a88a5dd2517..20d68b93128 100644 --- a/babel.config.js +++ b/babel.config.js @@ -8,7 +8,7 @@ module.exports = function config(api) { [ 'babel-plugin-react-compiler', { - target: '18', // should be the minimal supported version from peerDependencies + target: '19', // should be the minimal supported version from peerDependencies panicThreshold: 'all_errors', }, ], diff --git a/package-lock.json b/package-lock.json index b9c811cd00e..61d92eb58b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -160,11 +160,11 @@ "npm": ">=6.0.0" }, "peerDependencies": { - "@types/react": ">=18.0.0", - "@types/react-dom": ">=18.0.0", + "@types/react": ">=19.2.0", + "@types/react-dom": ">=19.2.0", "core-js": ">=3.0.0", - "react": ">=18.0.0", - "react-dom": ">=18.0.0", + "react": ">=19.2.0", + "react-dom": ">=19.2.0", "webpack": ">=5.0.0" }, "peerDependenciesMeta": { diff --git a/package.json b/package.json index 55d5faf95c5..1d46fbf7441 100644 --- a/package.json +++ b/package.json @@ -196,11 +196,11 @@ "xmlappend": "^1.0.4" }, "peerDependencies": { - "@types/react": ">=18.0.0", - "@types/react-dom": ">=18.0.0", + "@types/react": ">=19.2.0", + "@types/react-dom": ">=19.2.0", "core-js": ">=3.0.0", - "react": ">=18.0.0", - "react-dom": ">=18.0.0", + "react": ">=19.2.0", + "react-dom": ">=19.2.0", "webpack": ">=5.0.0" }, "peerDependenciesMeta": { diff --git a/src/auth-dialog-service/auth-dialog-service.tsx b/src/auth-dialog-service/auth-dialog-service.tsx index 6bc07195b2a..453afc3899e 100644 --- a/src/auth-dialog-service/auth-dialog-service.tsx +++ b/src/auth-dialog-service/auth-dialog-service.tsx @@ -17,9 +17,9 @@ type AuthDialogAttributes = React.JSX.LibraryManagedAttributes + - , + , ); } diff --git a/src/button-group/button-group.stories.tsx b/src/button-group/button-group.stories.tsx index 9146d0455ca..7fa2c21ee9b 100644 --- a/src/button-group/button-group.stories.tsx +++ b/src/button-group/button-group.stories.tsx @@ -87,12 +87,12 @@ export const buttonGroup = () => (
- +
diff --git a/src/button/button.stories.tsx b/src/button/button.stories.tsx index 8e13113d1ee..b109e41e035 100644 --- a/src/button/button.stories.tsx +++ b/src/button/button.stories.tsx @@ -28,7 +28,7 @@ single.parameters = {screenshots: {skip: true}}; export const basic = () => ( {[ControlsHeight.S, ControlsHeight.M, ControlsHeight.L].map(height => ( - + {[ {inline: false}, {primary: true, inline: false}, @@ -72,7 +72,7 @@ export const basic = () => (
))} -
+ ))}
); diff --git a/src/collapse/collapse-content.tsx b/src/collapse/collapse-content.tsx index a087ec2b13e..a34efc116d6 100644 --- a/src/collapse/collapse-content.tsx +++ b/src/collapse/collapse-content.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect, useRef, useContext, type PropsWithChildren} from 'react'; +import React, {useState, useEffect, useRef, use, type PropsWithChildren} from 'react'; import classNames from 'classnames'; import dataTests from '../global/data-tests'; @@ -29,7 +29,7 @@ export const CollapseContent: React.FC> = ({ minHeight = DEFAULT_HEIGHT, 'data-test': dataTest, }) => { - const {collapsed, duration, id, disableAnimation} = useContext(CollapseContext); + const {collapsed, duration, id, disableAnimation} = use(CollapseContext); const containerRef = useRef(null); const contentRef = useRef(null); const [initialContentHeight] = useState(minHeight); diff --git a/src/collapse/collapse-control.tsx b/src/collapse/collapse-control.tsx index fd6950ffbf0..f2552ee4678 100644 --- a/src/collapse/collapse-control.tsx +++ b/src/collapse/collapse-control.tsx @@ -1,4 +1,4 @@ -import {useContext, cloneElement} from 'react'; +import {use, cloneElement} from 'react'; import * as React from 'react'; import dataTests from '../global/data-tests'; @@ -19,7 +19,7 @@ interface Props { */ export const CollapseControl: React.FC = ({children, 'data-test': dataTest}) => { - const {setCollapsed, collapsed, id} = useContext(CollapseContext); + const {setCollapsed, collapsed, id} = use(CollapseContext); // eslint-disable-next-line @typescript-eslint/no-explicit-any const child: React.ReactElement = typeof children === 'function' ? children(collapsed) : children; diff --git a/src/collapse/collapse.tsx b/src/collapse/collapse.tsx index 3071b343e17..4465fe3344d 100644 --- a/src/collapse/collapse.tsx +++ b/src/collapse/collapse.tsx @@ -38,7 +38,7 @@ export const Collapse: React.FC> = ({ return (
- > = ({ }} > {children} - +
); }; diff --git a/src/confirm-service/confirm-service.tsx b/src/confirm-service/confirm-service.tsx index cb4e16b20b3..092f223e422 100644 --- a/src/confirm-service/confirm-service.tsx +++ b/src/confirm-service/confirm-service.tsx @@ -21,9 +21,9 @@ export const reactRoot = createRoot(containerElement); function renderConfirm(props: Props) { const {buttonsHeight = getGlobalControlsHeight(), ...restProps} = props; reactRoot.render( - + - , + , ); } diff --git a/src/date-picker/use-scroll-behavior.ts b/src/date-picker/use-scroll-behavior.ts index d6b3be50029..5494701c114 100644 --- a/src/date-picker/use-scroll-behavior.ts +++ b/src/date-picker/use-scroll-behavior.ts @@ -1,9 +1,8 @@ -import {useEffect, useLayoutEffect, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; import {type Locale} from 'date-fns'; import units, {isSafariOnIPhone, scrollerReRenderDelayIPhone, type CalendarProps, type ScrollDate} from './consts'; import {type ScrollArith} from './scroll-arith'; -import useEventCallback from '../global/use-event-callback'; import type scheduleRAF from '../global/schedule-raf'; @@ -20,7 +19,7 @@ export function useScrollBehavior( const containerRef = useRef(null); - const syncSelfState = useEventCallback((newScrollDate: ScrollDate['date']) => { + const syncSelfState = useEffectEvent((newScrollDate: ScrollDate['date']) => { const newScrollTopOnExistingItems = arith.getScrollTop(items, newScrollDate, locale); if (isNearEdge(newScrollTopOnExistingItems, containerRef.current!)) { const {newItems, newScrollTop} = arith.getItemsAndScrollTop(newScrollDate, locale); @@ -46,7 +45,7 @@ export function useScrollBehavior( syncSelfState(scrollDate.date); }, - [scrollDate, selfScrollDateSource, syncSelfState], + [scrollDate, selfScrollDateSource], ); const ignoreNextScrollEventRef = useRef(true); @@ -64,7 +63,7 @@ export function useScrollBehavior( const updateStateTimerRef = useRef(null); - const handleScroll = useEventCallback(() => { + const handleScroll = useEffectEvent(() => { scheduleScroll(() => { if (updateStateTimerRef.current != null) { window.clearTimeout(updateStateTimerRef.current); @@ -114,7 +113,7 @@ export function useScrollBehavior( updateStateTimerRef.current = null; } }; - }, [handleScroll]); + }, []); return {containerRef, items}; } diff --git a/src/dialog/dialog.tsx b/src/dialog/dialog.tsx index 3a725e0d753..aee53a82641 100644 --- a/src/dialog/dialog.tsx +++ b/src/dialog/dialog.tsx @@ -8,7 +8,7 @@ import {AdaptiveIsland} from '../island/island'; import getUID from '../global/get-uid'; import dataTests from '../global/data-tests'; import Shortcuts from '../shortcuts/shortcuts'; -import TabTrap, {type TabTrapProps} from '../tab-trap/tab-trap'; +import TabTrap, {type TabTrapObject, type TabTrapProps} from '../tab-trap/tab-trap'; import Button from '../button/button'; import {normalizePopupTarget, PopupTarget, PopupTargetContext} from '../popup/popup.target'; import {getPopupContainer} from '../popup/popup'; @@ -145,8 +145,8 @@ export default class Dialog extends PureComponent { }; dialog?: HTMLElement | null; - dialogRef = (tabTrap: TabTrap | null) => { - this.dialog = tabTrap && tabTrap.node; + dialogRef = (tabTrapObj: TabTrapObject | null) => { + this.dialog = tabTrapObj && tabTrapObj.node; }; nativeDialog = createRef(); diff --git a/src/dropdown-menu/dropdown-menu.tsx b/src/dropdown-menu/dropdown-menu.tsx index 56b40e003fe..c65cf677d23 100644 --- a/src/dropdown-menu/dropdown-menu.tsx +++ b/src/dropdown-menu/dropdown-menu.tsx @@ -1,5 +1,4 @@ import { - forwardRef, cloneElement, type ReactElement, type HTMLAttributes, @@ -33,7 +32,7 @@ export interface DropdownAnchorWrapperProps extends AnchorProps { interface DropdownMenuChildren { children?: DropdownChildrenFunction; popupMenuProps: { - ref: Ref>; + ref?: Ref>; data: readonly ListDataItem[] | undefined; id: string; ariaLabel: string; @@ -85,6 +84,7 @@ type OnSelectHandler = | undefined; export interface DropdownMenuProps extends Omit { + ref?: React.Ref | null>; anchor: | ReactElement | ReactNode[] @@ -97,14 +97,21 @@ export interface DropdownMenuProps extends Omit( - {id, anchor, ariaLabel, data, onSelect, menuProps, children, ...restDropdownProps}: DropdownMenuProps, - forwardedRef: Ref>, -) { +function DropdownMenu({ + ref, + id, + anchor, + ariaLabel, + data, + onSelect, + menuProps, + children, + ...restDropdownProps +}: DropdownMenuProps) { const [uid] = useState(() => getUID('dropdown-menu-list')); const listId = id || uid; const popupMenuProps: DropdownMenuChildren['popupMenuProps'] = { - ref: forwardedRef, + ref, id: listId, ariaLabel: ariaLabel || defaultAriaLabel, closeOnSelect: true, @@ -137,6 +144,6 @@ const DropdownMenu = forwardRef(function DropdownMenu( ); -}) as (props: DropdownMenuProps & {ref?: Ref}) => ReactElement | null; +} export default Object.assign(DropdownMenu, {ListProps: List.ListProps}); diff --git a/src/editable-heading/editable-heading.tsx b/src/editable-heading/editable-heading.tsx index 421228cf5c6..f94c1826639 100644 --- a/src/editable-heading/editable-heading.tsx +++ b/src/editable-heading/editable-heading.tsx @@ -1,4 +1,4 @@ -import {type InputHTMLAttributes, useEffect} from 'react'; +import {type InputHTMLAttributes, useEffect, useEffectEvent} from 'react'; import * as React from 'react'; import classNames from 'classnames'; @@ -7,7 +7,6 @@ import Button from '../button/button'; import {Size} from '../input/input'; import getUID from '../global/get-uid'; import Shortcuts from '../shortcuts/shortcuts'; -import useEventCallback from '../global/use-event-callback'; import inputStyles from '../input/input.css'; import styles from './editable-heading.css'; @@ -154,7 +153,7 @@ export const EditableHeading = (props: EditableHeadingProps) => { setIsMouseDown(true); }; - const onMouseMove = useEventCallback(() => { + const onMouseMove = useEffectEvent(() => { if (!isMouseDown) { return; } @@ -162,7 +161,7 @@ export const EditableHeading = (props: EditableHeadingProps) => { setIsInSelectionMode(true); }); - const onMouseUp = useEventCallback(() => { + const onMouseUp = useEffectEvent(() => { if (isMouseDown && !isInSelectionMode && !disabled) { onEdit(); } @@ -202,7 +201,7 @@ export const EditableHeading = (props: EditableHeadingProps) => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; - }, [onMouseMove, onMouseUp]); + }, []); return ( <> @@ -273,4 +272,4 @@ export const EditableHeading = (props: EditableHeadingProps) => { ); }; -export default React.memo(EditableHeading); +export default EditableHeading; diff --git a/src/expand/collapsible-group.tsx b/src/expand/collapsible-group.tsx index cb95c9137ac..06b5bbd1373 100644 --- a/src/expand/collapsible-group.tsx +++ b/src/expand/collapsible-group.tsx @@ -1,4 +1,4 @@ -import React, {forwardRef, useContext, useState} from 'react'; +import React, {use, useState} from 'react'; import classNames from 'classnames'; import chevronRightIcon from '@jetbrains/icons/chevron-12px-right'; import chevronDownIcon from '@jetbrains/icons/chevron-12px-down'; @@ -11,6 +11,7 @@ import {CollapseContext} from '../collapse/collapse-context'; import styles from './collapsible-group.css'; export interface CollapsibleGroupProps { + ref?: React.Ref; avatar?: React.ReactNode; title: React.ReactNode; subtitle?: React.ReactNode; @@ -33,7 +34,7 @@ interface CollapsibleGroupHeaderContentProps { type CollapsibleGroupHeaderProps = CollapsibleGroupHeaderContentProps & React.ButtonHTMLAttributes; function CollapsibleGroupHeaderContent({avatar, titleContent, subtitle}: CollapsibleGroupHeaderContentProps) { - const {collapsed} = useContext(CollapseContext); + const {collapsed} = use(CollapseContext); return ( @@ -57,7 +58,7 @@ function CollapsibleGroupHeaderContent({avatar, titleContent, subtitle}: Collaps } function CollapsibleGroupHeader({avatar, titleContent, subtitle, ...buttonProps}: CollapsibleGroupHeaderProps) { - const {setCollapsed, collapsed, id} = useContext(CollapseContext); + const {setCollapsed, collapsed, id} = use(CollapseContext); return ( ); } diff --git a/src/global/compose-refs.test.tsx b/src/global/compose-refs.test.tsx new file mode 100644 index 00000000000..4d7cf368767 --- /dev/null +++ b/src/global/compose-refs.test.tsx @@ -0,0 +1,105 @@ +import {useMemo, useRef} from 'react'; +import {render, screen} from '@testing-library/react'; + +import {useComposedRef} from './compose-refs'; + +describe('compose-refs', () => { + it('should install simple refs', () => { + let getCurrent1: () => HTMLDivElement | null; + let getCurrent2: () => HTMLDivElement | null; + + function TestComponent() { + const ref1 = useRef(null); + const ref2 = useRef(null); + + getCurrent1 = () => ref1.current; + getCurrent2 = () => ref2.current; + + const composedRef = useComposedRef(ref1, ref2); + return
; + } + + render(); + const div = screen.getByTestId('test-div'); + expect(div).to.exist; + expect(getCurrent1!()).to.equal(div); + expect(getCurrent2!()).to.equal(div); + }); + + it('should install function refs', () => { + let getCurrent1: () => HTMLDivElement | null; + let getCurrent2: () => HTMLDivElement | null; + let getCleanup2: () => boolean; + + function TestComponent({noRender}: {noRender?: boolean}) { + const ref1 = useRef(null); + const ref1Function = useMemo( + () => (value: HTMLDivElement | null) => { + ref1.current = value; + }, + [], + ); + + const ref2 = useRef(null); + const ref2Cleanup = useRef(false); + const ref2Function = useMemo( + () => (value: HTMLDivElement | null) => { + ref2.current = value; + return () => { + ref2Cleanup.current = true; + }; + }, + [], + ); + + getCurrent1 = () => ref1.current; + getCurrent2 = () => ref2.current; + getCleanup2 = () => ref2Cleanup.current; + + const composedRef = useComposedRef(ref1Function, ref2Function); + if (noRender) return null; + + return
; + } + + const {rerender} = render(); + const div = screen.getByTestId('test-div'); + expect(div).to.exist; + expect(getCurrent1!()).to.equal(div); + expect(getCurrent2!()).to.equal(div); + expect(getCleanup2!()).to.equal(false); + + rerender(); + rerender(); + const newDiv = screen.getByTestId('test-div'); + expect(newDiv).to.exist; + expect(newDiv).to.not.equal(div); + expect(getCurrent1!()).to.equal(newDiv); + expect(getCurrent2!()).to.equal(newDiv); + expect(getCleanup2!()).to.equal(true); + }); + + it('should be stable', () => { + let currentComposedRef: ReturnType | null = null; + + function TestComponent({dataVal}: {dataVal?: string}) { + const ref1 = useRef(null); + const ref2 = useRef(null); + + const composedRef = useComposedRef(ref1, ref2); + currentComposedRef = composedRef; + return
; + } + + const {rerender} = render(); + expect(screen.getByTestId('test-div')).to.exist; + const firstRenderComposedRef = currentComposedRef; + expect(firstRenderComposedRef).to.exist; + + rerender(); + expect(screen.getByTestId('test-div')).to.exist; + const secondRenderComposedRef = currentComposedRef; + expect(secondRenderComposedRef).to.exist; + expect(firstRenderComposedRef).to.equal(secondRenderComposedRef); + }); +}); diff --git a/src/global/compose-refs.ts b/src/global/compose-refs.ts index f14c1515cf3..e98909dfb90 100644 --- a/src/global/compose-refs.ts +++ b/src/global/compose-refs.ts @@ -1,17 +1,32 @@ -import {type Ref, type MutableRefObject} from 'react'; +import {useMemo, type Ref, type RefObject} from 'react'; import memoizeOne from 'memoize-one'; function composeRefs(...refs: (Ref | undefined)[]) { - return (value: T | null) => + return (value: T | null) => { + const cleanups: (() => void)[] = []; refs.forEach(ref => { if (typeof ref === 'function') { - ref(value); + const cleanup = ref(value); + if (typeof cleanup === 'function') { + cleanups.push(cleanup); + } } else if (ref) { - (ref as MutableRefObject).current = value; + (ref as RefObject).current = value; } }); + return () => cleanups.forEach(cleanup => cleanup()); + }; } export function createComposedRef() { return memoizeOne(composeRefs); } + +export function useComposedRef(...refs: (Ref | undefined)[]): Ref { + /** + * The React Compiler doesn't allow non-literal arrays in useMemo + * dependency lists, so we still use memoizeOne under the hood. + */ + const composer = useMemo(() => createComposedRef(), []); + return composer(...refs); +} diff --git a/src/global/composeRefs.ts b/src/global/composeRefs.ts deleted file mode 100644 index 7cd42ff0285..00000000000 --- a/src/global/composeRefs.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable unicorn/filename-case */ -import {createComposedRef as _createComposedRef} from './compose-refs'; - -/** - * @deprecated Use createComposedRef from './compose-refs' instead - */ -const createComposedRef = _createComposedRef; -export {createComposedRef}; diff --git a/src/global/focus-with-temporary-tabindex.ts b/src/global/focus-with-temporary-tabindex.ts new file mode 100644 index 00000000000..a46e10a6be6 --- /dev/null +++ b/src/global/focus-with-temporary-tabindex.ts @@ -0,0 +1,23 @@ +/** + * Focuses an element, temporarily adding `tabindex="0"` if necessary. + * + * If the element does not already have a `tabindex` attribute, one is + * added before focusing and automatically removed when the element loses + * focus. + * + * This is useful when implementing roving tabindex patterns on elements + * that are not normally focusable. + */ +export function focusWithTemporaryTabIndex(element: HTMLElement): void { + if (!element.hasAttribute('tabindex')) { + element.tabIndex = 0; + + function onBlur() { + element.removeAttribute('tabindex'); + element.removeEventListener('blur', onBlur); + } + element.addEventListener('blur', onBlur); + } + + element.focus(); +} diff --git a/src/global/intersection-observer-context.ts b/src/global/intersection-observer-context.ts new file mode 100644 index 00000000000..224a820bf0c --- /dev/null +++ b/src/global/intersection-observer-context.ts @@ -0,0 +1,126 @@ +import {createContext, type RefObject, use, useEffect, useState} from 'react'; + +/** + * Provides access to a shared `IntersectionObserver` instance + * via the {@link IntersectionObserverContext} context. + * + * @see IntersectionObserverContext + */ +export interface IntersectionObserverHandle { + /** + * Starts observing an element. + * + * Returns a cleanup function that stops observing it. + */ + observe(element: Element, isIntersecting: (isIntersecting: boolean) => void): () => void; +} + +/** + * @internal + */ +const noopIntersectionObserverHandle: IntersectionObserverHandle = { + observe: () => () => {}, +}; + +/** + * Multiple components can share a single `IntersectionObserver` instance through this context. + * + * Usage: + * + * ```tsx + * + * + * + * + * function YourComponent() { + * // Contains the current isIntersecting value + * const isIntersecting = useIsIntersecting(elementRef); + * + * // Or, to manually work with the IntersectionObserverHandle: + * const handle = use(IntersectionObserverContext); + * useEffect(() => { + * return handle.observe(elementRef.current, isIntersecting => { ... }) + * }) + * } + * ``` + */ +export const IntersectionObserverContext = createContext(noopIntersectionObserverHandle); + +/** + * Creates an IntersectionObserverHandle suitable for {@link IntersectionObserverContext}. + */ +export function useIntersectionObserverHandle( + rootRef?: RefObject, + rootMargin?: number, + scrollMargin?: number, +) { + const [handle, setHandle] = useState(noopIntersectionObserverHandle); + + useEffect(() => { + const root = rootRef?.current; + + const callbacksByElement = new Map void)[]>(); + + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + const callbacks = callbacksByElement.get(entry.target); + callbacks?.forEach(cb => cb(entry.isIntersecting)); + } + }, + { + root, + rootMargin: rootMargin != null ? `${rootMargin}px` : undefined, + scrollMargin: scrollMargin != null ? `${scrollMargin}px` : undefined, + }, + ); + + setHandle({ + observe(element, onChange) { + if (!callbacksByElement.has(element)) { + callbacksByElement.set(element, []); + observer.observe(element); + } + callbacksByElement.get(element)!.push(onChange); + + return () => { + const callbacks = callbacksByElement.get(element); + if (!callbacks) return; + + const index = callbacks.indexOf(onChange); + if (index !== -1) { + callbacks.splice(index, 1); + } + if (!callbacks.length) { + callbacksByElement.delete(element); + observer.unobserve(element); + } + }; + }, + }); + + return () => { + observer.disconnect(); + setHandle(noopIntersectionObserverHandle); + }; + }, [rootRef, rootMargin, scrollMargin]); + + return handle; +} + +/** + * Returns whether the referenced element is currently intersecting. + */ +export function useIsIntersecting(elementRef: RefObject) { + const handle = use(IntersectionObserverContext); + const [isIntersecting, setIsIntersecting] = useState(false); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + return handle.observe(element, setIsIntersecting); + }, [handle, elementRef]); + + return isIntersecting; +} diff --git a/src/global/is-within-interactive-element.ts b/src/global/is-within-interactive-element.ts new file mode 100644 index 00000000000..75476273633 --- /dev/null +++ b/src/global/is-within-interactive-element.ts @@ -0,0 +1,27 @@ +const interactiveSelector = [ + 'a', + 'button', + 'details', + 'input', + 'label', + 'option', + 'select', + 'summary', + 'textarea', + '[contenteditable]', + '[role="button"]', + '[role="checkbox"]', + '[role="link"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', +].join(); + +/** + * If this function returns `false`, the event may be interpreted as a click + * or tap on a "empty space" rather than on an interactive element such as + * a button or a link. + */ +export function isWithinInteractiveElement(target: EventTarget | null): boolean { + return target instanceof Element && target.closest(interactiveSelector) != null; +} diff --git a/src/global/is-within-navigable-element.ts b/src/global/is-within-navigable-element.ts new file mode 100644 index 00000000000..f6d9c13da73 --- /dev/null +++ b/src/global/is-within-navigable-element.ts @@ -0,0 +1,28 @@ +const navigableSelector = [ + 'input:not([type="button"]):not([type="checkbox"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="image"]):not([type="reset"]):not([type="submit"])', + 'textarea', + 'select', + '[contenteditable]', + '[role="combobox"]', + '[role="grid"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menubar"]', + '[role="radiogroup"]', + '[role="searchbox"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="tablist"]', + '[role="textbox"]', + '[role="tree"]', + '[role="treegrid"]', +].join(); + +/** + * Returns whether the event target is within an element that is expected to + * handle keyboard navigation itself, so container-level keyboard shortcuts + * should generally be ignored. + */ +export function isWithinNavigableElement(target: EventTarget | null): boolean { + return target instanceof Element && target.closest(navigableSelector) != null; +} diff --git a/src/global/parse-css-duration.ts b/src/global/parse-css-duration.ts new file mode 100644 index 00000000000..cf71f0ad051 --- /dev/null +++ b/src/global/parse-css-duration.ts @@ -0,0 +1,12 @@ +/** + * Parses a CSS duration string (e.g., "500ms", "2s") and returns the duration in milliseconds. + * Returns the `defaultVal` if the input string is not a valid CSS duration. + */ +export function parseCssDuration(duration: string, defaultVal = 0): number { + const match = duration.match(/(\d+(\.\d+)?)(s|ms)/); + if (!match) return defaultVal; + const value = parseFloat(match[1]); + const unit = match[3]; + // eslint-disable-next-line no-magic-numbers + return unit === 's' ? value * 1000 : value; +} diff --git a/src/global/schedule-with-cleanup.ts b/src/global/schedule-with-cleanup.ts new file mode 100644 index 00000000000..97f71e4a926 --- /dev/null +++ b/src/global/schedule-with-cleanup.ts @@ -0,0 +1,37 @@ +/** + * Schedules a timeout and returns a function that cancels it. + * + * Useful from React effects. + */ +export function setTimeoutWithCleanup(callback: () => void, delay: number = 0): () => void { + let timerId: number | null = window.setTimeout(() => { + callback(); + timerId = null; + }, delay); + + return () => { + if (timerId != null) { + window.clearTimeout(timerId); + timerId = null; + } + }; +} + +/** + * Schedules an animation frame and returns a function that cancels it. + * + * Useful from React effects. + */ +export function requestAnimationFrameWithCleanup(callback: () => void): () => void { + let rafId: number | null = requestAnimationFrame(() => { + callback(); + rafId = null; + }); + + return () => { + if (rafId != null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; +} diff --git a/src/legacy-table/selection.ts b/src/global/table-selection.ts similarity index 94% rename from src/legacy-table/selection.ts rename to src/global/table-selection.ts index cdde3f6b9d1..c8360027a8f 100644 --- a/src/legacy-table/selection.ts +++ b/src/global/table-selection.ts @@ -6,7 +6,7 @@ export interface SelectionItem { id: string | number; } -export interface TableSelectionConfig { +export interface TableSelectionConfig { data?: readonly T[] | undefined; selected?: Set | undefined; focused?: T | null | undefined; @@ -21,7 +21,7 @@ export interface CloneWithConfig { focused?: T | null | undefined; } -export default class Selection { +export default class TableSelection { protected _rawData: readonly T[]; protected _getChildren: (item: T) => readonly T[]; protected _data: Set; @@ -35,11 +35,11 @@ export default class Selection { focused = null, getKey = (item: T) => { // Default behavior stays backward compatible: use item's "id" if present - if ('id' in item) { + if (item && typeof item === 'object' && 'id' in item) { return (item as {id: string | number}).id; } // If there's no id provided on item and no getKey supplied, fail fast with a clear message - throw new Error('Selection: getKey is required when items have no "id" property'); + throw new Error('TableSelection: getKey is required when items have no "id" property'); }, getChildren = () => [], isItemSelectable = () => true, @@ -86,7 +86,7 @@ export default class Selection { const newFocused = focused === undefined ? this._focused : focused; - return new (this.constructor as typeof Selection)({ + return new (this.constructor as typeof TableSelection)({ data: newData, selected: newSelected, focused: data && !focused ? cloneFocus() : newFocused, diff --git a/src/legacy-table/multitable.tsx b/src/legacy-table/multitable.tsx index 9fd6b44670a..893c4bc7b52 100644 --- a/src/legacy-table/multitable.tsx +++ b/src/legacy-table/multitable.tsx @@ -1,7 +1,7 @@ import {PureComponent, Children, cloneElement, type ReactElement} from 'react'; import {type TableAttrs} from './table'; -import {type SelectionItem} from './selection'; +import {type SelectionItem} from '../global/table-selection'; type TableComponent = ReactElement>; diff --git a/src/legacy-table/selection-adapter.ts b/src/legacy-table/selection-adapter.ts index 0893e827e6d..2df960d2ed3 100644 --- a/src/legacy-table/selection-adapter.ts +++ b/src/legacy-table/selection-adapter.ts @@ -1,7 +1,7 @@ -import type Selection from './selection'; -import type {SelectionItem} from './selection'; +import type TableSelection from '../global/table-selection'; +import type {SelectionItem} from '../global/table-selection'; -export default function selectionAdapter(getSelection: () => Selection) { +export default function selectionAdapter(getSelection: () => TableSelection) { return { get size() { return getSelection().getActive().size; diff --git a/src/legacy-table/selection-shortcuts-hoc.tsx b/src/legacy-table/selection-shortcuts-hoc.tsx index 5036f9913c8..ebe65260877 100644 --- a/src/legacy-table/selection-shortcuts-hoc.tsx +++ b/src/legacy-table/selection-shortcuts-hoc.tsx @@ -2,19 +2,19 @@ import {PureComponent, type ComponentClass} from 'react'; import {type ShortcutsMap} from '../shortcuts/core'; -import type Selection from './selection'; +import type TableSelection from '../global/table-selection'; export interface SelectionShortcutsOuterProps { - selection: Selection; + selection: TableSelection; selectable?: boolean | undefined; - onSelect?: ((selection: Selection) => void) | undefined; + onSelect?: ((selection: TableSelection) => void) | undefined; shortcuts?: ShortcutsMap | undefined; } export interface SelectionShortcutsAddProps { - selection: Selection; + selection: TableSelection; selectable: boolean; - onSelect: (selection: Selection) => void; + onSelect: (selection: TableSelection) => void; shortcutsMap: ShortcutsMap; } @@ -58,7 +58,7 @@ export default function selectionShortcutsHOC) => { + shiftSelect = (selection: TableSelection) => { if (this.shiftSelectionMode === 'addition') { return selection.select(); } diff --git a/src/legacy-table/simple-table.stories.tsx b/src/legacy-table/simple-table.stories.tsx index 693e3a0fa5c..381f0da42f8 100644 --- a/src/legacy-table/simple-table.stories.tsx +++ b/src/legacy-table/simple-table.stories.tsx @@ -4,7 +4,7 @@ import {type StoryFn} from '@storybook/react-webpack5'; import Link from '../link/link'; import {type TableAttrs} from './table'; import SimpleTable from './simple-table'; -import {type SelectionItem} from './selection'; +import {type SelectionItem} from '../global/table-selection'; import {type SortParams} from './header-cell'; import mock from './table.stories.json'; diff --git a/src/legacy-table/simple-table.tsx b/src/legacy-table/simple-table.tsx index e0f60d03343..96a8bf45785 100644 --- a/src/legacy-table/simple-table.tsx +++ b/src/legacy-table/simple-table.tsx @@ -2,7 +2,7 @@ import {PureComponent} from 'react'; import classNames from 'classnames'; import Table, {type TableAttrs} from './table'; -import Selection, {type SelectionItem} from './selection'; +import TableSelection, {type SelectionItem} from '../global/table-selection'; import style from './legacy-table.css'; @@ -17,7 +17,7 @@ class SimpleTable extends PureComponent extends Omit, 'selection' | 'onSelect'> { - onSelectionChange: (selection: Selection) => void; - selection?: Selection; + onSelectionChange: (selection: TableSelection) => void; + selection?: TableSelection; } class SmartTable extends PureComponent> { static defaultProps = { @@ -13,7 +13,7 @@ class SmartTable extends PureComponent extends PureComponent) => { + onSelect = (selection: TableSelection) => { this.setState({selection}); this.props.onSelectionChange(selection); }; diff --git a/src/legacy-table/table.stories.tsx b/src/legacy-table/table.stories.tsx index b61db0db3ae..b2c52509251 100644 --- a/src/legacy-table/table.stories.tsx +++ b/src/legacy-table/table.stories.tsx @@ -7,7 +7,7 @@ import Pager from '../pager/pager'; import Button from '../button/button'; import Table, {Table as BaseTable, type TableAttrs} from './table'; import MultiTable from './multitable'; -import Selection from './selection'; +import TableSelection from '../global/table-selection'; import {type SortParams} from './header-cell'; import mock from './table.stories.json'; @@ -46,7 +46,7 @@ type BasicAction = } | { type: 'setSelection'; - payload: Selection; + payload: TableSelection; } | { type: 'setSort'; @@ -63,7 +63,7 @@ interface BasicStateInput { } interface BasicState extends BasicStateInput { data: Item[]; - selection: Selection; + selection: TableSelection; } const isItemSelectable = (item: Item) => item.id !== 14; function processState(input: BasicStateInput): BasicState { @@ -72,7 +72,7 @@ function processState(input: BasicStateInput): BasicState { data.sort((a, b) => String(a[sortKey]).localeCompare(String(b[sortKey])) * (sortOrder ? 1 : -1)); data = data.slice((page - 1) * PAGE_SIZE, (page - 1) * PAGE_SIZE + PAGE_SIZE); - const selection = new Selection({data, isItemSelectable}); + const selection = new TableSelection({data, isItemSelectable}); return {...input, data, selection}; } @@ -100,7 +100,7 @@ export const Basic: StoryFn = args => { }), ); const setData = (payload: Item[]) => dispatch({type: 'setData', payload}); - const setSelection = (payload: Selection) => dispatch({type: 'setSelection', payload}); + const setSelection = (payload: TableSelection) => dispatch({type: 'setSelection', payload}); const setSort = (payload: SortParams) => dispatch({type: 'setSort', payload}); const setPage = (payload: number) => dispatch({type: 'setPage', payload}); @@ -239,8 +239,8 @@ const data1 = tableData.continents; const data2 = tableData.countries; export const MultiTableStory = () => { - const [selection1, setSelection1] = useState(new Selection({data: data1})); - const [selection2, setSelection2] = useState(new Selection({data: data2})); + const [selection1, setSelection1] = useState(new TableSelection({data: data1})); + const [selection2, setSelection2] = useState(new TableSelection({data: data2})); const columns1 = [ { @@ -286,7 +286,7 @@ export const MultiTableStory = () => { MultiTableStory.storyName = 'multi table'; export const EmptyTable: StoryFn> = ({onSelect, ...restProps}) => { - const [selection, setSelection] = useState>(new Selection({})); + const [selection, setSelection] = useState>(new TableSelection({})); return ( > = args => { - const [selection] = useState(new Selection({})); + const [selection] = useState(new TableSelection({})); return
; }; @@ -415,8 +415,8 @@ WithCustomColumns.args = { WithCustomColumns.storyName = 'Table with custom rows'; export const CustomGetKey: StoryFn>> = ({onSelect, data, ...restProps}) => { - const [selection, setSelection] = useState>>( - new Selection({ + const [selection, setSelection] = useState>>( + new TableSelection({ data, }), ); diff --git a/src/table/default-item-renderer.tsx b/src/table/default-item-renderer.tsx new file mode 100644 index 00000000000..fa39a26f66d --- /dev/null +++ b/src/table/default-item-renderer.tsx @@ -0,0 +1,151 @@ +import {type ComponentPropsWithRef, type Context, type Key, use, useCallback, useRef} from 'react'; +import classNames from 'classnames'; + +import {ColumnAnimationContext, TablePropsContext} from './table-const'; +import {useComposedRef} from '../global/compose-refs'; +import {useItemVirtualization} from './item-virtualization'; +import {TableCell, TableRow} from './table-primitives'; + +import type {TableProps} from './table-props'; + +import styles from './table.css'; + +export interface DefaultItemRendererProps { + /** + * The index of the `data` item to render. + */ + index: number; + + /** + * If `true`, the row will be focusable with up/down arrow keys. + * Focus is implemented using the + * ["roving tabindex"](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets#technique_1_roving_tabindex) + * technique, that is, only the focused row has `tabIndex={0}`. + */ + keyboardFocusable?: boolean; + + /** + * Changes the background on hover and applies the pointer cursor. + * Note that `false` does not mean the row cannot handle `onClick`. + */ + clickable?: boolean; + + /** + * If `true`, the row is highlighted as selected with a different background color. + */ + selected?: boolean; + + /** + * The nesting level of an item. Applies an indent for columns with + * `Column.indent` set to `true`. `0`, negative values, and an unset value + * mean no indent. + */ + level?: number; + + /** + * When set to `true`, does not control item virtualization. + * Useful when you include `DefaultItemRenderer` as a part of a custom row renderer, + * and track the visibility yourself. + */ + noItemVirtualization?: boolean; +} + +/** + * Standard component for rendering a table row. + * + * Renders an item using the table's column definitions and lets you + * configure item-scoped behavior such as selection, keyboard navigation, + * event handlers, `className`, and `ref`. + * + * Note that row-level click and keyboard handlers are not discoverable + * by assistive technologies. Make sure that any functionality relying on + * them (such as selection or expand/collapse) is also available through + * accessible controls, such as checkboxes or buttons with accessible + * labels. + */ +export function DefaultItemRenderer({ + index, + keyboardFocusable, + clickable, + selected, + level, + noItemVirtualization, + + ref: userRef, + className, + ...restProps +}: DefaultItemRendererProps & ComponentPropsWithRef<'tr'>) { + const localRef = useRef(null); + const composedRef = useComposedRef(userRef, localRef); + + useItemVirtualization({ + index, + refs: localRef, + onIntersectionChange: useCallback( + ([isIntersecting], _i, [element]) => + isIntersecting === false && + !noItemVirtualization && + element?.isConnected && + !element.contains(document.activeElement) && + !element.previousElementSibling?.contains(document.activeElement) && + !element.nextElementSibling?.contains(document.activeElement) + ? element.getBoundingClientRect().height + : undefined, + [noItemVirtualization], + ), + }); + + const tableProps = use(TablePropsContext as Context | null>); + if (!tableProps) { + return null; + } + + const animatedColumn = use(ColumnAnimationContext); + + const {data, columns} = tableProps; + const item = data[index]; + + const indentSize = 24; + + return ( + + {columns.map((column, columnIndex) => { + const {key, tdClassName, indent} = column; + + return ( + 0 ? {paddingInlineStart: `${level * indentSize}px`} : undefined} + > + {column.renderCell?.(item, index, data) ?? getDefaultCellValue(item, columnIndex, key)} + + ); + })} + + ); +} + +function getDefaultCellValue(item: T, columnIndex: number, columnKey: Key) { + if (Array.isArray(item)) { + return String(item[columnIndex]); + } + + if (item !== null && typeof item === 'object') { + return String((item as Record)[String(columnKey)]); + } + + if (columnIndex === 0) { + return String(item); + } + + return ''; +} diff --git a/src/table/internal/column-animation.ts b/src/table/internal/column-animation.ts new file mode 100644 index 00000000000..211dd9f7b4e --- /dev/null +++ b/src/table/internal/column-animation.ts @@ -0,0 +1,91 @@ +import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'; + +import {parseCssDuration} from '../../global/parse-css-duration'; +import {requestAnimationFrameWithCleanup, setTimeoutWithCleanup} from '../../global/schedule-with-cleanup'; + +import type {Column} from '../table-props'; +import type {ColumnAnimation} from '../table-const'; + +import styles from '../table.css'; + +const reorderExpectationTimeout = 1000; + +export interface ReorderSpec { + fromIndex: number; + insertionIndex: number; +} + +export type ExpectColumnReorder = (reorderSpec: ReorderSpec) => void; + +export function useColumnAnimation({ + disabled, + tableRef, + columns, +}: { + disabled: boolean | undefined; + tableRef: RefObject; + columns: readonly Column[]; +}) { + const [columnAnimation, setColumnAnimation] = useState(null); + + const pendingColumnReorder = useRef[]}>(null); + + const expectColumnReorder = useCallback( + (reorderSpec: ReorderSpec) => { + if (disabled) return; + + const timerId = window.setTimeout(() => { + pendingColumnReorder.current = null; + }, reorderExpectationTimeout); + pendingColumnReorder.current = {...reorderSpec, timerId, columns}; + }, + [disabled, columns], + ); + + useEffect(() => { + return () => { + if (pendingColumnReorder.current) { + window.clearTimeout(pendingColumnReorder.current.timerId); + pendingColumnReorder.current = null; + } + }; + }, []); + + useEffect(() => { + const table = tableRef.current; + if (!table || !pendingColumnReorder.current || columns === pendingColumnReorder.current.columns) return; + + const {fromIndex, insertionIndex} = pendingColumnReorder.current; + pendingColumnReorder.current = null; + + const columnIndex = fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex; + return requestAnimationFrameWithCleanup(() => + setColumnAnimation(prev => + prev == null ? {columnIndex, phase: 'initial', cellClassName: styles.animatedColumnInitial} : prev, + ), + ); + }, [columns, tableRef]); + + useEffect(() => { + if (columnAnimation?.phase === 'initial') { + return requestAnimationFrameWithCleanup(() => + setColumnAnimation(prev => + prev === columnAnimation ? {...prev, phase: 'fade-out', cellClassName: styles.animatedColumnFadeOut} : prev, + ), + ); + } + + if (columnAnimation?.phase === 'fade-out') { + const fadeOutMs = parseCssDuration( + window.getComputedStyle(tableRef.current!).getPropertyValue('--animated-column-fade-out-duration'), + ); + return setTimeoutWithCleanup( + () => setColumnAnimation(prev => (prev === columnAnimation ? null : prev)), + fadeOutMs, + ); + } + + return undefined; + }, [columnAnimation, tableRef]); + return {columnAnimation, expectColumnReorder}; +} diff --git a/src/table/internal/table-header.tsx b/src/table/internal/table-header.tsx new file mode 100644 index 00000000000..863671e4caf --- /dev/null +++ b/src/table/internal/table-header.tsx @@ -0,0 +1,577 @@ +/* eslint-disable no-nested-ternary, max-lines */ +import {type ComponentPropsWithRef, type Context, use, useCallback, useRef, useState, type PointerEvent} from 'react'; +import classNames from 'classnames'; +import arrowDownIcon from '@jetbrains/icons/arrow-12px-down'; +import arrowUpIcon from '@jetbrains/icons/arrow-12px-up'; +import dragIcon from '@jetbrains/icons/drag-12px'; +import settingsIcon from '@jetbrains/icons/settings-12px'; +import trashIcon from '@jetbrains/icons/trash-12px'; +import unsortedIcon from '@jetbrains/icons/unsorted-12px'; + +import {type TableProps} from '../table-props'; +import {ColumnAnimationContext, TablePropsContext} from '../table-const'; +import {type ExpectColumnReorder} from './column-animation'; +import Icon from '../../icon'; +import {useComposedRef} from '../../global/compose-refs'; +import {isWithinInteractiveElement} from '../../global/is-within-interactive-element'; +import {parseCssDuration} from '../../global/parse-css-duration'; + +import styles from '../table.css'; + +export function TableHeader({expectColumnReorder}: {expectColumnReorder: ExpectColumnReorder}) { + const {columns, noHeader, stickyHeader, columnEditing, onColumnEditingRequest, theadClassName, theadTrClassName} = + use(TablePropsContext as Context>); + + const [localColumnEditing, setLocalColumnEditing] = useState(false); + const effectiveColumnEditing = columnEditing ?? localColumnEditing; + + const toggleColumnEditing = useCallback( + (source: 'header' | 'edit-button') => { + let newColumnEditing: boolean; + if (columnEditing == null) { + newColumnEditing = !localColumnEditing; + setLocalColumnEditing(newColumnEditing); + } else { + newColumnEditing = !columnEditing; + } + + onColumnEditingRequest?.(newColumnEditing, source); + }, + [columnEditing, localColumnEditing, onColumnEditingRequest], + ); + + const handleTheadClick = useCallback( + (e: React.MouseEvent) => { + if (window.matchMedia('(hover: none)').matches && !isWithinInteractiveElement(e.target)) { + toggleColumnEditing('header'); + } + }, + [toggleColumnEditing], + ); + + const handleEditColumnsButtonClick = useCallback(() => { + toggleColumnEditing('edit-button'); + }, [toggleColumnEditing]); + + if (noHeader) return null; + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events + + + {columns.map((column, columnIndex) => ( + + ))} + + + ); +} + +function TableHeaderCell({ + columnIndex, + columnEditing, + handleEditColumnsButtonClick, + expectColumnReorder, +}: { + columnIndex: number; + columnEditing: boolean; + handleEditColumnsButtonClick: () => void; + expectColumnReorder: ExpectColumnReorder; +}) { + const {columns, columnEditButton} = use(TablePropsContext as Context>); + const {key, name, renderHeader, sortOrder, deletable, canReorder, thClassName} = columns[columnIndex]; + + const animatedColumn = use(ColumnAnimationContext); + const children = renderHeader ? renderHeader() : (name ?? String(key)); + + return ( + + ); +} + +function SortButton({ + columnIndex, + className, + children, + onClick, + ...restProps +}: {columnIndex: number} & ComponentPropsWithRef<'button'>) { + const tableProps = use(TablePropsContext as Context | null>); + const column = tableProps?.columns[columnIndex]; + + const sortOrder = column?.sortOrder; + const glyph = + sortOrder === 'none' + ? unsortedIcon + : sortOrder === 'ascending' + ? arrowUpIcon + : sortOrder === 'descending' + ? arrowDownIcon + : undefined; + + const handleClick = useCallback( + (e: React.MouseEvent) => { + onClick?.(e); + if (!e.defaultPrevented) { + const nextOrder = sortOrder === 'ascending' ? 'descending' : sortOrder === 'descending' ? 'none' : 'ascending'; + tableProps!.onSort?.(columnIndex, nextOrder, tableProps!.columns); + } + }, + [columnIndex, onClick, sortOrder, tableProps], + ); + + if (!tableProps || !column) { + return null; + } + + return ( + + ); +} + +function DeleteColumnButton({ + columnIndex, + className, + onClick, + ...restProps +}: {columnIndex: number} & ComponentPropsWithRef<'button'>) { + const tableProps = use(TablePropsContext as Context | null>); + const column = tableProps?.columns[columnIndex]; + + const handleClick = useCallback( + (e: React.MouseEvent) => { + onClick?.(e); + if (!e.defaultPrevented) { + tableProps!.onColumnDelete?.(columnIndex, tableProps!.columns); + } + }, + [columnIndex, onClick, tableProps], + ); + + if (!tableProps || !column) { + return null; + } + + const hint = `Delete column ${column.name ?? String(column.key)}.`; + + return ( + + ); +} + +function EditColumnsButton({columnEditing, ...props}: {columnEditing: boolean} & ComponentPropsWithRef<'button'>) { + const {className, ...restProps} = props; + const hint = columnEditing ? 'Hide column controls.' : 'Show column controls.'; + return ( + + ); +} + +function ColumnReorderHandle({ + columnIndex, + expectColumnReorder, + ref: userRef, + className, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture, + onKeyDown, + ...restProps +}: {columnIndex: number; expectColumnReorder: ExpectColumnReorder} & ComponentPropsWithRef<'button'>) { + const tableProps = use(TablePropsContext as Context | null>); + const column = tableProps?.columns[columnIndex]; + const canReorder = column?.canReorder; + const canReorderCb = useCallback( + (index: number) => + canReorder === true || (typeof canReorder === 'function' && canReorder(index, tableProps!.columns)), + [canReorder, tableProps], + ); + + const localRef = useRef(null); + const composedRef = useComposedRef(localRef, userRef); + + const activeDragRef = useRef<{ + state: 'is-dragging' | 'ended-with-no-change'; + startColumnIndex: number; + startClientX: number; + headerTopClientY: number; + columnsClientX: {l: number; r: number}[]; + indicatorHeight: string; + cleanup: () => void; + }>(null); + + const getDragFrame = useCallback(() => { + return document.body.querySelector(`.${styles.columnDragFrame}`) as HTMLDivElement | null; + }, []); + + const renderDragFrame = useCallback( + (clientX: number) => { + const {startColumnIndex, startClientX, headerTopClientY, columnsClientX, indicatorHeight} = + activeDragRef.current!; + const {l, r} = columnsClientX[startColumnIndex]; + + let dragFrame = getDragFrame(); + if (!dragFrame) { + dragFrame = document.createElement('div'); + dragFrame.className = styles.columnDragFrame; + dragFrame.style.setProperty('top', `calc(max(0px, ${headerTopClientY - 2}px))`); + dragFrame.style.setProperty('width', `${r - l}px`); + dragFrame.style.setProperty('height', indicatorHeight); + document.body.appendChild(dragFrame); + } + + dragFrame.style.setProperty('left', `${l + clientX - startClientX}px`); + }, + [getDragFrame], + ); + + const translateXButton = useCallback((clientX: number) => { + if (!localRef.current || !activeDragRef.current) return; + + const {startClientX} = activeDragRef.current; + localRef.current.style.setProperty('transform', `translateX(${clientX - startClientX}px)`); + }, []); + + const getClosestInsertionPoint = useCallback( + (clientX: number) => { + let bestDistance = Infinity; + let index = -1; + let after = false; + activeDragRef.current?.columnsClientX.forEach(({l, r}, i) => { + const distanceToLeft = Math.abs(l - clientX); + const distanceToRight = Math.abs(r - clientX); + if (distanceToLeft < bestDistance && canReorderCb(i)) { + bestDistance = distanceToLeft; + index = i; + after = false; + } + if (distanceToRight < bestDistance && canReorderCb(i + 1)) { + bestDistance = distanceToRight; + index = i; + after = true; + } + }); + return {index, after}; + }, + [canReorderCb], + ); + + const getInsertionIndicator = useCallback(() => { + return document.body.querySelector(`.${styles.columnInsertionIndicator}`) as HTMLDivElement | null; + }, []); + + const renderInsertionIndicator = useCallback( + (insertionPoint: ReturnType) => { + const {index, after} = insertionPoint; + const {headerTopClientY, columnsClientX, indicatorHeight} = activeDragRef.current!; + const {l, r} = columnsClientX[index]; + + let indicator = getInsertionIndicator(); + if (!indicator) { + indicator = document.createElement('div'); + indicator.className = styles.columnInsertionIndicator; + indicator.style.setProperty('top', `${headerTopClientY}px`); + indicator.style.setProperty('height', indicatorHeight); + document.body.appendChild(indicator); + } + + indicator.style.setProperty('left', `${(after ? r : l) - 1}px`); + }, + [getInsertionIndicator], + ); + + const cleanupDrag = useCallback(() => { + if (activeDragRef.current) { + activeDragRef.current.cleanup(); + activeDragRef.current = null; + } + + const btn = localRef.current; + if (btn) { + btn.style.removeProperty('transform'); + btn.style.removeProperty('transition'); + } + + getDragFrame()?.remove(); + getInsertionIndicator()?.remove(); + }, [getDragFrame, getInsertionIndicator]); + + const animateNoChangeThenCleanup = useCallback(() => { + if (activeDragRef.current?.state === 'is-dragging') { + activeDragRef.current.state = 'ended-with-no-change'; + activeDragRef.current.cleanup(); + activeDragRef.current.cleanup = () => {}; + + const dragFrame = getDragFrame(); + if (dragFrame) { + const {columnsClientX, startColumnIndex} = activeDragRef.current; + dragFrame.style.left = `${columnsClientX[startColumnIndex].l}px`; + dragFrame.style.opacity = '0'; + dragFrame.style.transition = 'left var(--ring-ease), opacity var(--ring-ease)'; + } + + const indicator = getInsertionIndicator(); + if (indicator) { + indicator.style.opacity = '0'; + indicator.style.transition = 'opacity var(--ring-ease)'; + } + + const btn = localRef.current; + if (btn) { + btn.style.transform = 'translateX(0)'; + btn.style.transition = 'transform var(--ring-ease)'; + } + } + + const ringEaseMs = parseCssDuration( + window.getComputedStyle(document.documentElement).getPropertyValue('--ring-ease'), + ); + setTimeout(cleanupDrag, ringEaseMs); + }, [cleanupDrag, getDragFrame, getInsertionIndicator]); + + const handlePointerDown = useCallback( + (e: PointerEvent) => { + onPointerDown?.(e); + if (e.defaultPrevented) return; + + const {clientX: startClientX, pointerId, currentTarget} = e; + const thead = currentTarget.closest('thead'); + const table = thead?.closest('table'); + if (!thead || !table) return; + + const headerTopClientY = thead.getBoundingClientRect().top; + + const columnsClientX = [...thead.querySelectorAll('th')].map(th => { + const rect = th.getBoundingClientRect(); + return {l: rect.x, r: rect.x + rect.width}; + }); + + const {bottom} = table.getBoundingClientRect(); + const visibleTableHeight = bottom - headerTopClientY; + const viewportBottomRelativeToHeaderTop = window.innerHeight - headerTopClientY; + const indicatorHeight = `min(${visibleTableHeight}px, calc(${viewportBottomRelativeToHeaderTop}px - .5rem))`; + + currentTarget.setPointerCapture(pointerId); + function keydownListener(keyEvent: KeyboardEvent) { + if (keyEvent.key === 'Escape') { + animateNoChangeThenCleanup(); + keyEvent.stopPropagation(); + keyEvent.preventDefault(); + } + } + document.addEventListener('keydown', keydownListener); // In Safari, the button is not focused + currentTarget.style.cursor = 'grabbing'; + + activeDragRef.current = { + state: 'is-dragging', + startColumnIndex: columnIndex, + startClientX, + headerTopClientY, + columnsClientX, + indicatorHeight, + cleanup: () => { + document.removeEventListener('keydown', keydownListener); + currentTarget.releasePointerCapture(pointerId); + currentTarget.style.removeProperty('cursor'); + }, + }; + + renderDragFrame(startClientX); + + e.preventDefault(); + }, + [animateNoChangeThenCleanup, columnIndex, onPointerDown, renderDragFrame], + ); + + const handlePointerMove = useCallback( + (e: PointerEvent) => { + onPointerMove?.(e); + if (e.defaultPrevented || activeDragRef.current?.state !== 'is-dragging') return; + + const {clientX} = e; + renderDragFrame(clientX); + translateXButton(clientX); + + const insertionPoint = getClosestInsertionPoint(clientX); + if (insertionPoint) renderInsertionIndicator(insertionPoint); + }, + [getClosestInsertionPoint, translateXButton, onPointerMove, renderDragFrame, renderInsertionIndicator], + ); + + const handlePointerUp = useCallback( + (e: PointerEvent) => { + onPointerUp?.(e); + if (e.defaultPrevented || activeDragRef.current?.state !== 'is-dragging') return; + + const table = e.currentTarget.closest('table'); + if (!table) { + cleanupDrag(); + return; + } + + const {index, after} = getClosestInsertionPoint(e.clientX); + const insertionIndex = after ? index + 1 : index; + if (insertionIndex === columnIndex || insertionIndex === columnIndex + 1) { + animateNoChangeThenCleanup(); + return; + } + + cleanupDrag(); + expectColumnReorder({fromIndex: columnIndex, insertionIndex}); + + tableProps!.onColumnReorder?.(columnIndex, insertionIndex, tableProps!.columns); + }, + [ + animateNoChangeThenCleanup, + cleanupDrag, + columnIndex, + expectColumnReorder, + getClosestInsertionPoint, + onPointerUp, + tableProps, + ], + ); + + const handlePointerCancel = useCallback( + (e: PointerEvent) => { + onPointerCancel?.(e); + if (e.defaultPrevented) return; + + animateNoChangeThenCleanup(); + }, + [animateNoChangeThenCleanup, onPointerCancel], + ); + + const handleLostPointerCapture = useCallback( + (e: PointerEvent) => { + onLostPointerCapture?.(e); + if (e.defaultPrevented) return; + + animateNoChangeThenCleanup(); + }, + [animateNoChangeThenCleanup, onLostPointerCapture], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + onKeyDown?.(e); + if (e.defaultPrevented || !tableProps) return; + + const left = e.key === 'ArrowLeft'; + const right = e.key === 'ArrowRight'; + if (!left && !right) return; + + const initialInsertionIndex = left ? columnIndex - 1 : columnIndex + 2; + const step = left ? -1 : 1; + // eslint-disable-next-line yoda + for (let i = initialInsertionIndex; 0 <= i && i <= tableProps.columns.length; i += step) { + if (canReorderCb(i)) { + expectColumnReorder({fromIndex: columnIndex, insertionIndex: i}); + tableProps.onColumnReorder?.(columnIndex, i, tableProps.columns); + e.preventDefault(); + return; + } + } + }, + [canReorderCb, columnIndex, expectColumnReorder, onKeyDown, tableProps], + ); + + if (!tableProps || !column) { + return null; + } + + const hint = `Reorder column ${column.name ?? String(column.key)}.`; + const description = 'Use Left and Right arrow keys to move the column.'; + + return ( + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ); +} + +/** + * Reserves the space for the reorder handle and prevents layout shift when the handle appears on hover. + */ +function ColumnReorderHandleMirror() { + return + + ); +} diff --git a/src/table/item-virtualization.ts b/src/table/item-virtualization.ts new file mode 100644 index 00000000000..ce44fccd173 --- /dev/null +++ b/src/table/item-virtualization.ts @@ -0,0 +1,74 @@ +import {type RefObject, use, useEffect} from 'react'; + +import {IntersectionObserverContext} from '../global/intersection-observer-context'; +import {CollapseItemIntoSpacerContext} from './internal/virtual-items'; + +/** + * Use in an item renderer to control item virtualization. + */ +export function useItemVirtualization({ + index, + refs, + onIntersectionChange, +}: { + /** + * Index of the item. + */ + index: number; + + /** + * One or more elements representing this item. + * + * When multiple elements are provided, the virtualization callback receives + * the intersection state of all of them. + * + * If you pass multiple refs, memoize the array (for example with `useMemo()`) + * to avoid restarting observation on every render. + */ + refs: RefObject | RefObject[]; + + /** + * Invoked when the `isIntersecting` state of the observed elements changes. + * Consider wrapping a callback to `useCallback()` to avoid restarting observation on every render. + * + * @param intersectionStates - Current intersection state of every observed element. + * Entries are initially `undefined` until the corresponding element + * has been reported by `IntersectionObserver`. + * @param changedIndex - Index of the element whose intersection state changed. + * @param elements - The observed elements. Note that some elements may + * already be disconnected from the DOM when this callback is invoked. + * Use additional checks like `element.isConnected`. + * @returns Return the height of an item to collapse the item into spacer, + * or `undefined` to keep the item rendered. + */ + onIntersectionChange: ( + intersectionStates: (boolean | undefined)[], + changedIndex: number, + elements: (Element | null)[], + ) => number | undefined; +}) { + const handle = use(IntersectionObserverContext); + const collapseItemIntoSpacer = use(CollapseItemIntoSpacerContext); + + useEffect(() => { + const intersectionStates: (boolean | undefined)[] = Array.isArray(refs) ? refs.map(() => undefined) : [undefined]; + const elements = Array.isArray(refs) ? refs.map(r => r.current) : [refs.current]; + + const cleanups: (() => void)[] = []; + elements.forEach((element, elementIndex) => { + if (!element) return; + + const cleanup = handle.observe(element, isIntersecting => { + intersectionStates[elementIndex] = isIntersecting; + + const height = onIntersectionChange(intersectionStates, elementIndex, elements); + if (height != null) { + collapseItemIntoSpacer(index, height); + } + }); + cleanups.push(cleanup); + }); + + return () => cleanups.forEach(cleanup => cleanup()); + }, [collapseItemIntoSpacer, handle, index, onIntersectionChange, refs]); +} diff --git a/src/table/table-const.ts b/src/table/table-const.ts new file mode 100644 index 00000000000..6bf05191b48 --- /dev/null +++ b/src/table/table-const.ts @@ -0,0 +1,50 @@ +import {createContext} from 'react'; + +import type {TableProps} from './table-props'; + +/** + * Use anywhere inside the table to get access to the props passed to it. + * Cast to `Context>` in usage place. + */ +export const TablePropsContext = createContext | null>(null); + +/** + * Information about a column reorder animation. + * + * Available through {@link ColumnAnimationContext} to allow custom cell + * renderers to animate reordered columns consistently with the default + * table renderer. + */ +export interface ColumnAnimation { + /** + * Index of the column being animated. + */ + columnIndex: number; + + /** + * Current animation phase. + */ + phase: 'initial' | 'fade-out'; + + /** + * CSS class to apply to the animated cell or another element used to + * render the animation. + * + * The class only defines the `background-color` and `transition` + * properties. + */ + cellClassName: string; +} + +/** + * Provides information about the currently animated column. + * + * Use in a custom cell renderer to animate reordered columns + * consistently with the default table renderer. + */ +export const ColumnAnimationContext = createContext(null); + +/** + * When a row only contains unformatted single-line text, it will be exactly of this height. + */ +export const defaultRowHeight = 37; diff --git a/src/table/table-primitives.tsx b/src/table/table-primitives.tsx new file mode 100644 index 00000000000..81ff0c928ae --- /dev/null +++ b/src/table/table-primitives.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames'; + +import type {ComponentPropsWithRef} from 'react'; + +import styles from './table.css'; + +export interface TableRowProps { + /** + * @see DefaultItemRendererProps.keyboardFocusable + */ + keyboardFocusable?: boolean; +} + +/** + * @internal + */ +export const keyboardFocusableAttrName = 'data-keyboard-focusable'; + +/** + * A helper `` component for custom {@link TableProps.renderItem} implementations. + * Applies the standard row class names. + */ +export function TableRow(props: TableRowProps & ComponentPropsWithRef<'tr'>) { + const {keyboardFocusable, className, ...restProps} = props; + const classes = classNames(styles.row, className); + const trRestProps = keyboardFocusable ? {[keyboardFocusableAttrName]: '', ...restProps} : restProps; + return ; +} + +/** + * A helper `` element. + */ + theadClassName?: string; + + /** + * Applied to the only `` element within the ``. + */ + theadTrClassName?: string; + + /** + * Applied to the `` element. + */ + tbodyClassName?: string; +} + +export type SortOrder = Extract; + +/** + * The column specification. + */ +export interface Column { + /** + * Used as a key in the columns list. + */ + key: React.Key; + + /** + * Used in `aria-label`s of column controls which do not contain text, + * such as the delete column button. If not set, the `String(key)` is used. + */ + name?: string; + + /** + * Renders the content of the column header, excluding controls such as + * the sort and delete buttons. If not specified, the default behavior is + * `name ?? String(key)`. + */ + renderHeader?: () => ReactNode; + + /** + * Renders the value of a single cell. If not specified, the default + * behavior is: + * + * - If `item` is an `Array`, renders `String(item[columnIndex])` + * - If `item` is an `Object`, renders `String(item[String(columnKey)])` + * - Otherwise: + * - The first column renders `String(item)` + * - Other columns render an empty string + */ + renderCell?: (item: T, index: number, items: readonly T[]) => ReactNode; + + /** + * If the column gets an indent when `DefaultItemRendererProps.level` returns + * a positive number. + */ + indent?: boolean; + + /** + * If set, displays sort button and includes `aria-sort` in the column header. + * Handle clicks with {@link TableProps.onSort}. + */ + sortOrder?: AriaAttributes['aria-sort']; + + /** + * Whether to display a delete button in the column header. + * Handle delete requests with {@link TableProps.onColumnDelete}. + * Make sure {@link Column.name} or {@link Column.key} is meaningful, + * as it will be included in the `aria-label` of the delete button. + */ + deletable?: boolean; + + /** + * Displays a reorder handle in the column header. + * Handle reorder requests with {@link TableProps.onColumnReorder}. + * If a function is provided, it determines whether the column may be moved + * to the specified insertion position. + * + * Make sure {@link Column.name} or {@link Column.key} is meaningful, + * as it will be included in the `aria-label` of the reorder button. + */ + canReorder?: boolean | ((insertionIndex: number, columns: readonly Column[]) => boolean); + + /** + * The class name to apply to the `th` element inside `table > thead`. + */ + thClassName?: string; + + /** + * The class name to apply to the `td` element inside `table > tbody`. + * If a custom `TableProps.renderItem` is provided, this prop is not used, + * unless the custom renderer falls back to the `DefaultItemRenderer`. + */ + tdClassName?: string | ((item: T, index: number, items: readonly T[]) => string | undefined); +} diff --git a/src/table/table.css b/src/table/table.css new file mode 100644 index 00000000000..e7e917a5517 --- /dev/null +++ b/src/table/table.css @@ -0,0 +1,242 @@ +@import '../global/variables.css'; + +.table { + --animated-column-fade-out-duration: 600ms; + + width: 100%; + + border-spacing: 0; + + border-style: none; +} + +.stickyHeader { + position: sticky; + z-index: var(--ring-overlay-z-index); + + top: 0; + + background-color: rgba(var(--ring-content-background-components), 0.85); + backdrop-filter: blur(8px); +} + +@property --header-cell-shadow-color { + syntax: ''; + inherits: true; + initial-value: transparent; +} + +.headerCell { + margin: 0; + + padding: calc(var(--ring-unit) * 0.75) var(--ring-unit); + + transition: background-color var(--ring-fast-ease), --header-cell-shadow-color var(--ring-fast-ease); + + border-bottom: 1px solid var(--ring-line-color); + + box-shadow: inset 1px 0 0 var(--header-cell-shadow-color), 1px 0 0 var(--header-cell-shadow-color); + + &:hover { + --header-cell-shadow-color: var(--ring-line-color); + + &:not(.animatedColumnInitial, .animatedColumnFadeOut) { + background-color: var(--ring-grey-container-light-color); + } + } +} + +.headerCellInnerWrapper { + display: flex; + justify-content: start; + + color: var(--ring-secondary-color); + + font-size: var(--ring-font-size-smaller); + font-weight: normal; + + .sortAndHeader { + flex-grow: 1; + + text-align: left; + white-space: nowrap; + } + + .rightButtons { + margin-left: 4px; + } +} + +.headerButton { + margin: unset; + padding: unset; + + cursor: pointer; + + transition: color var(--ring-fast-ease); + text-align: unset; + + color: unset; + border: unset; + background: unset; + + font: unset; + + &:hover { + color: var(--ring-main-hover-color); + } + + &:focus-visible { + border-radius: 2px; + outline: 2px solid var(--ring-button-focus-border-color); + } + + &:focus-within { + color: var(--ring-main-hover-color); + } +} + +.deleteColumnButton { + pointer-events: none; + + color: transparent; + + .theadColumnEditing &, + .headerCell:hover &, + &:focus { + pointer-events: unset; + } + + /* stylelint-disable-next-line selector-max-specificity */ + .theadColumnEditing &:not(:focus), + /* stylelint-disable-next-line selector-max-specificity */ + .headerCell:hover &:not(:focus) { + color: unset; + } + + /* stylelint-disable-next-line selector-max-specificity */ + .headerCell:hover &:hover:not(:focus), + &:focus { + color: var(--ring-main-hover-color); + } +} + +.columnReorderHandle { + overflow: hidden; + + width: 0; + + cursor: grab; + + transition: color var(--ring-fast-ease), width var(--ring-fast-ease), margin-right var(--ring-fast-ease); + + border-radius: var(--ring-border-radius); + touch-action: none; +} + +.theadColumnEditing .columnReorderHandle, +.headerCell:hover .columnReorderHandle, +.columnReorderHandle:focus-visible { + width: 12px; + margin-right: 4px; +} + +.columnReorderHandleMirror { + display: inline-block; + + width: 12px; + margin-left: 4px; + + transition: width var(--ring-fast-ease), margin-left var(--ring-fast-ease); +} + +.theadColumnEditing .columnReorderHandleMirror, +.headerCell:hover .columnReorderHandleMirror, +.columnReorderHandle:focus-visible ~ .columnReorderHandleMirror { + width: 0; + margin-left: 0; +} + +.editColumnsButton { + & svg { + transition: transform var(--ring-fast-ease); + } + + .theadColumnEditing & svg { + transform: rotate(90deg); + } +} + +.columnDragFrame { + position: fixed; + + z-index: var(--ring-overlay-z-index); + + pointer-events: none; + + border: 2px solid var(--ring-border-accent-color); + border-radius: calc(2 * var(--ring-border-radius)); + + background-color: rgba(var(--ring-border-accent-components), 0.06); +} + +.columnInsertionIndicator { + position: fixed; + + z-index: var(--ring-overlay-z-index); + + width: 2px; + + background-color: var(--ring-main-color); +} + +.animatedColumnInitial { + transition: none; + + background-color: rgba(var(--ring-border-accent-components), 0.12); +} + +.animatedColumnFadeOut { + transition: background-color var(--animated-column-fade-out-duration) ease-out; + + background-color: transparent; +} + +.spacerRow { + margin: 0; + padding: 0; + + border: 0; +} + +.spacerCell { + margin: 0; + padding: 0; + + border: 0; +} + +.row:focus { + border-radius: var(--ring-border-radius); + outline: 2px solid var(--ring-button-focus-border-color); +} + +.clickableRow { + cursor: pointer; + + transition: background-color var(--ring-fast-ease); + + &:not(.selectedRow):hover { + background-color: var(--ring-hover-background-color); + } +} + +.selectedRow { + background-color: var(--ring-selected-background-color); +} + +.cell { + padding: var(--ring-unit); + + border-bottom: 1px solid var(--ring-line-color); +} diff --git a/src/table/table.stories.css b/src/table/table.stories.css new file mode 100644 index 00000000000..fc684631083 --- /dev/null +++ b/src/table/table.stories.css @@ -0,0 +1,226 @@ +/* stylelint-disable selector-max-specificity */ +.tdUrl { + overflow: hidden; + + max-width: 100px; + + white-space: nowrap; + text-overflow: ellipsis; +} + +.thWithCheckbox label { + color: unset; +} + +.scroller { + overflow-y: scroll; + + height: 300px; +} + +.scrollerBottom { + overflow-y: scroll; + + height: 300px; + + margin-top: calc(100vh - 320px); +} + +.noChildrenChevronPadding { + padding-left: 16px; +} + +.tdWithChevron { + box-sizing: border-box; + width: 190px; +} + +.chevron { + transition: transform var(--ring-fast-ease); +} + +.chevronExpanded { + transform: rotate(90deg); +} + +.tcColumnEditingCheckboxWr { + padding-left: 46px; +} + +/* Not visible but accessible for screen readers */ +.srOnly { + position: absolute; + + overflow: hidden; + clip: rect(0 0 0 0); /* legacy fallback */ + + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + + border: 0; + clip-path: inset(50%); +} + +.teamCityBuilds { + max-width: 100%; + margin-left: 4px; + + table-layout: fixed; + + & :is(.build, .details) > td > div { + overflow: hidden; + + white-space: nowrap; + text-overflow: ellipsis; + } + + & thead > tr > th:first-child, + & tbody > tr > td:first-child { + border-bottom-style: none; + } + + &:has(.build:first-child:focus) > thead > tr > th:first-child, + &:has(.details:nth-child(2):focus) > thead > tr > th:first-child, + & tbody > tr:focus > td:first-child, + & tbody > tr:has(+ :focus) > td:first-child, + & tbody > tr:has(+ tr + .details:focus) > td:first-child, + & tbody > tr:focus + .details > td:first-child { + border-bottom-style: solid; + } +} + +.chevronTh { + width: 12px; + + /* No on-hover bg+borders change */ + + pointer-events: none; +} + +.chevronTd { + & svg { + transition: color var(--ring-fast-ease), color var(--ring-fast-ease); + + color: transparent; + + .build:hover &, + .build:focus-within &, + .build:has(+ .details:focus-within) & { + color: var(--ring-icon-color); + } + } + + &:hover svg, + &:focus-within svg { + color: var(--ring-main-color) !important; + } +} + +.chevronPlaceholder { + width: 12px; +} + +.checkboxTh { + width: 48px; + + pointer-events: none; +} + +.starIcon { + margin-left: 4px; + + transform: translateY(1px); + + color: var(--ring-icon-color); + + & svg { + width: 16px; + } +} + +.idTh { + width: 60px; +} + +.build:has(+ .details) > td { + border-bottom-color: rgba(var(--ring-line-components), 0.5); +} + +:is(.build, .details) > td:first-child { + position: relative; + + &::before { + position: absolute; + top: -1px; + left: -4px; + + width: 4px; + height: calc(100% + 2px); + + content: ''; + + border-top-left-radius: var(--ring-border-radius); + border-bottom-left-radius: var(--ring-border-radius); + } +} + +:is(.build:focus + .details, .build:has(+ .details:focus)) > td:first-child::before { + background-color: rgba(var(--ring-main-components), 0.3); +} + +.build:has(+ .details) > td:first-child::before { + border-bottom-left-radius: 0; +} + +.build + .details > td:first-child::before { + border-top-left-radius: 0; +} + +.build:focus, +.details:focus { + outline: none; + + & > td:first-child::before { + background-color: var(--ring-main-color); + } +} + +.teamCityBuildDetails { + width: calc(100% - 55px); + margin-left: 55px; + + & td { + padding-top: calc(var(--ring-unit) * 0.5); + padding-bottom: calc(var(--ring-unit) * 0.25); + + vertical-align: top; + + border: 0; + + &:first-child { + font-weight: 600; + } + } + + & ul { + margin: 0; + } +} + +.details { + position: relative; +} + +.columnAnimationEmulator { + position: absolute; + top: 0; + left: 0; + + width: 0; + + height: 100%; + + pointer-events: none; +} diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx new file mode 100644 index 00000000000..d2c0a340914 --- /dev/null +++ b/src/table/table.stories.tsx @@ -0,0 +1,1494 @@ +/* eslint-disable no-nested-ternary, react-hooks/rules-of-hooks */ +import { + type ComponentType, + use, + useCallback, + useEffect, + useEffectEvent, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; +import chevronIcon from '@jetbrains/icons/chevron-12px-right'; +import starEmptyIcon from '@jetbrains/icons/star-empty-20px'; +import starFilledIcon from '@jetbrains/icons/star-filled-20px'; +import classNames from 'classnames'; +import {addHours, format, formatDuration, intervalToDuration} from 'date-fns'; + +import Table from './table'; +import Link from '../link/link'; +import TableSelection from '../global/table-selection'; +import Checkbox from '../checkbox/checkbox'; +import Tag, {TagType} from '../tag/tag'; +import {DefaultItemRenderer} from './default-item-renderer'; +import Icon from '../icon/icon'; +import Button from '../button/button'; +import {focusWithTemporaryTabIndex} from '../global/focus-with-temporary-tabindex'; +import {createRandom} from '../util-stories'; +import {ColumnAnimationContext} from './table-const'; +import {isWithinInteractiveElement} from '../global/is-within-interactive-element'; +import {useItemVirtualization} from './item-virtualization'; +import {TableCell, TableRow} from './table-primitives'; + +import type {SortOrder, Column} from './table-props'; +import type {Meta, StoryObj} from '@storybook/react'; + +import countriesData from '../legacy-table/table.stories.json' with {type: 'json'}; + +import style from './table.stories.css'; + +const meta = { + title: 'Components/Table', + component: Table, +} as Meta>; + +const waitAndCapture = [ + {type: 'wait', delay: 300}, + {type: 'capture', name: 'light', selector: '[id=storybook-root] table'}, +]; + +export default meta; + +type TableStory = StoryObj>; + +const smallDataSlice = countriesData.slice(10, 16); + +const getKey = ({id}: {id: number | string}) => id; + +function PlaceLink({href, dataTest}: {href: string; dataTest?: string}) { + return ( + + {href} + + ); +} + +export const BasicWithMultiselect: TableStory<(typeof smallDataSlice)[number]> = { + args: { + data: smallDataSlice, + columns: [ + {key: 'ID'}, + {key: 'country', name: 'Country'}, + {key: 'city', name: 'City'}, + { + key: 'URL', + renderCell: ({url}) => , + tdClassName: style.tdUrl, + }, + ], + getKey, + }, + + render(args) { + const [selection, setSelection] = useState(() => new TableSelection({data: args.data})); + + const columns = useMemo(() => { + const [idColumn, ...restColumns] = args.columns; + const allSelected = args.data.every(item => selection.isSelected(item)); + return [ + { + ...idColumn, + + renderHeader: () => ( + 0 && !allSelected} + checked={allSelected} + onChange={e => setSelection(e.target.checked ? selection.selectAll() : selection.resetSelection())} + label='ID' + /> + ), + + thClassName: style.thWithCheckbox, + + renderCell: item => ( + setSelection(e.target.checked ? selection.select(item) : selection.deselect(item))} + label={String(item.id)} + /> + ), + }, + ...restColumns, + ] satisfies typeof args.columns; + }, [args, selection]); + + return ( +
+
+
+ {canReorder && } + {sortOrder ? ( + + {children} + + ) : ( + children + )} + {canReorder && } +
+ +
+ {deletable && } + {columnIndex === columns.length - 1 && columnEditButton && ( + + )} +
+
+
+
` component for custom {@link TableProps.renderItem} implementations. + * Applies the standard cell class names, but not data-dependent `tdClassName`. + */ +export function TableCell(props: ComponentPropsWithRef<'td'>) { + const {className, ...restProps} = props; + const classes = classNames(styles.cell, className); + return ; +} diff --git a/src/table/table-props.tsx b/src/table/table-props.tsx new file mode 100644 index 00000000000..1b4d73b2d0a --- /dev/null +++ b/src/table/table-props.tsx @@ -0,0 +1,313 @@ +import type {AriaAttributes, ReactNode, RefObject} from 'react'; + +export interface TableProps { + /** + * The data items to render. `null` and `undefined` items are not supported. + * Referentially identical items are not supported either. + */ + data: readonly T[]; + + /** + * Column definitions. + */ + columns: readonly Column[]; + + /** + * Used as a key in the items list. + */ + getKey: (item: T, index: number, items: readonly T[]) => React.Key; + + /** + * If `true`, the table header will not be rendered. + * + * Note that this may impact accessibility. If necessary, provide additional + * information via `aria-label` or `aria-description` on the `Table` element. + */ + noHeader?: boolean; + + /** + * If true, renders a sticky header. + */ + stickyHeader?: boolean; + + /** + * Called when the user clicks the sort button in a column header. + * The client is expected to update the `columns` prop with the new + * sort order for the corresponding column, and update the data accordingly. + */ + onSort?: (columnIndex: number, newOrder: SortOrder, columns: readonly Column[]) => void; + + /** + * Called when the user clicks on a column delete button in the header. + * The client is expected to update the `columns` prop with the column removed. + */ + onColumnDelete?: (columnIndex: number, columns: readonly Column[]) => void; + + /** + * Called when the user reorders columns by dragging a column. + * The `insertionIndex` parameter represents an insertion position in the original, + * unchanged `columns` array before the column is removed. + * + * One possible implementation is: + * + * ```ts + * const [moved] = columns.splice(fromIndex, 1); + * columns.splice(fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex, 0, moved); + * ``` + * + * The callback is not called when the reorder operation would not change the + * column order, i.e. when + * `insertionIndex === fromIndex || insertionIndex === fromIndex + 1`. + */ + onColumnReorder?: (fromIndex: number, insertionIndex: number, columns: readonly Column[]) => void; + + /** + * By default, when a column is reordered, the moved column is highlighted + * with a temporary background color. Set `true` to disable this animation. + */ + noColumnReorderAnimation?: boolean; + + /** + * Customizes how an item is rendered. + * + * Return `DefaultItemRenderer` to configure row-specific behavior such as + * `clickable`, `keyboardFocusable`, event handlers, `className`, or `ref`. + * + * You can also return custom row(s) instead. See the `Table` documentation + * for details. + */ + renderItem?: (item: T, index: number, items: readonly T[]) => ReactNode; + + /** + * Only renders rows near the viewport. + * + * Rows may transition between two states: + * - materialized: rendered as actual table rows. This happens when + * the corresponding spacer approaches the viewport, as specified by + * `lookaheadPx`. + * - virtualized: replaced with spacer rows of the same height. This happens + * when the row moves sufficiently far from the viewport, as specified by + * `retentionMarginPx`. + * + * Toggling this prop should normally work as expected, but a seamless + * transition is not guaranteed: the scroll position may reset to the top. + * However, if row height estimates are accurate and the data has not + * changed, the browser may apply scroll anchoring, resulting in a smoother + * transition, possibly with brief flickering but without scroll jumps. + */ + virtualizeRows?: boolean; + + /** + * Used with `virtualizeRows` as the source of scroll events, the target of + * `ResizeObserver`, and the root of `IntersectionObserver`. Required when + * the scrollable container is not the whole document. + * + * If not set: + * - the scroll listener is attached to `window` + * - `ResizeObserver` observes `document.body` + * - `IntersectionObserver` has no root (i.e. the viewport is used) + * + * Note that if this scroller is nested inside another scrollable container, + * the outer container is not tracked. As a result, items may not materialize + * until the inner scroller is scrolled. + * + * Support for nested scroll containers may be added in the future. + */ + scrollerRef?: RefObject; + + /** + * Used with `virtualizeRows` to estimate the height of items that have not + * been rendered yet. The function should be fast and side-effect free. + * Do not measure the DOM here. Once a row is rendered, its actual height + * will be measured and used instead of this estimate. + * + * Note the effects of imprecise estimates: + * - When the height is underestimated, the table may materialize more rows + * than specified by `lookaheadPx`. If the resulting rows extend beyond + * `retentionMarginPx`, they will be virtualized again. If this causes + * relayout flickering, increase `retentionMarginPx`. + * - When the height is overestimated, the table may materialize fewer rows + * than specified by `lookaheadPx`, which may leave a spacer partially + * visible. To avoid this, increase `lookaheadPx` (and `retentionMarginPx` + * accordingly, since it should be greater than `lookaheadPx`). + * + * Default: 37px = 16px padding + 20px line height + 1px border. + */ + estimateHeight?: (item: T, index: number, items: readonly T[]) => number; + + /** + * When using `virtualizeRows`, the number of pixels above and below + * the viewport to materialize in advance. + * + * Increase this value if blank space becomes visible during fast scrolling. + * + * Default: 400px. + */ + lookaheadPx?: number; + + /** + * Used with `virtualizeRows`. Additional margin around the viewport before + * materialized rows become eligible for virtualization. + * + * Increasing this value reduces row churn when heights are underestimated. + * In that case, the table may materialize more rows than needed and then + * immediately virtualize them again. A larger margin keeps such rows + * rendered for longer, at the cost of rendering more rows overall. + * + * This value should be greater than `lookaheadPx`. Increase it if you notice + * table relayouts during initial render or scrolling. + * + * Default: 450px. + */ + retentionMarginPx?: number; + + /** + * When using `virtualizeRows`, ignore scroll and resize position changes + * smaller than this value. + * + * Measurement inaccuracies and rounding artifacts may slightly change the + * table layout during materialization and virtualization. With scroll + * anchoring enabled (the default browser behavior), the browser may then + * adjust the scroll position, triggering additional scroll or resize events. + * Small deltas are ignored to prevent such feedback loops from causing + * oscillations at virtualization boundaries. + * + * Increase if you expect high inaccuracy in height measurements, or if you + * notice oscillations at virtualization boundaries. + * + * Default: 50px. + */ + minScrollAndResizeDeltaPx?: number; + + /** + * "Column editing mode" is a mode in which controls that are normally hidden + * become visible, such as column reorder and delete buttons. + * + * When this prop is `undefined`, the component manages the mode internally. + * Users can toggle it by tapping the table header on mobile or by clicking + * the column edit button, if enabled. Since tapping the table header is not + * discoverable by assistive technologies, it's recommended to enable + * `columnEditButton` when using the internal mode. + * + * Alternatively, pass `true` or `false` to control the mode externally. + */ + columnEditing?: boolean; + + /** + * Called when the user requests to enter or leave column editing mode. + * + * The `source` parameter indicates what triggered the request. + * + * When `columnEditing` is not controlled, the component automatically + * applies the requested change internally. + * + * When `columnEditing` is controlled externally and you still want to + * respond to user requests, use this callback to decide whether to + * update the mode. + */ + onColumnEditingRequest?: (editing: boolean, source: 'header' | 'edit-button') => void; + + /** + * Whether to show a small gear button in the top-right corner that + * toggles column editing mode. + * + * For accessibility, it's recommended to enable this button unless you + * provide an external control for toggling column editing mode. + */ + columnEditButton?: boolean; + + /** + * Applied to the `
( + { + if (!isWithinInteractiveElement(e.target)) { + setSelection(selection.toggleSelection(item)); + e.preventDefault(); + } + }} + /> + )} + /> + ); + }, + + parameters: { + screenshots: { + actions: [ + {type: 'click', selector: 'tbody tr:nth-child(2) td:nth-child(1) input[type="checkbox"]'}, + {type: 'click', selector: 'tbody tr:nth-child(4) td:nth-child(3)'}, + ...waitAndCapture, + ], + }, + }, +}; + +export const WithAllColumnControls: TableStory<(typeof smallDataSlice)[number]> = { + args: { + data: smallDataSlice, + columns: [ + { + key: 'id', + name: 'ID', + canReorder: true, + }, + { + key: 'country', + name: 'Country', + sortOrder: 'none', + deletable: true, + canReorder: true, + }, + { + key: 'city', + name: 'City', + sortOrder: 'none', + deletable: true, + canReorder: true, + }, + { + key: 'url', + name: 'URL', + deletable: true, + renderCell: ({url}) => , + canReorder: true, + tdClassName: style.tdUrl, + }, + ], + getKey, + }, + + render(args) { + const [data, setData] = useState(args.data); + const [columns, setColumns] = useState(args.columns); + + function handleColumnDelete(columnIndex: number) { + setColumns(columns.filter((_, i) => i !== columnIndex)); + } + + return ( +
+ sortByColumn(args.data, columns, columnIndex, sortOrder, setData, setColumns) + } + onColumnDelete={handleColumnDelete} + onColumnReorder={(fromIndex, insertionIndex) => reorderColumns(columns, fromIndex, insertionIndex, setColumns)} + columnEditButton + /> + ); + }, + + parameters: { + screenshots: { + actions: [ + {type: 'click', selector: 'button[aria-label="Show column controls."]'}, + {type: 'click', selector: 'button[aria-label="Delete column City."]'}, + ...waitAndCapture, + ], + }, + }, +}; + +type Priority = 'Trivial' | 'Minor' | 'Normal' | 'Major' | 'Critical' | 'Blocker'; +const priorities = ['Trivial', 'Minor', 'Normal', 'Major', 'Critical', 'Blocker'] satisfies Priority[]; + +function sortByColumn( + data: readonly T[], + columns: readonly Column[], + columnIndex: number, + sortOrder: SortOrder, + setData: (data: readonly T[]) => void, + setColumns: (columns: readonly Column[]) => void, +) { + setColumns(getColumnsWithSortOrder(columns, columnIndex, sortOrder)); + + if (sortOrder === 'none') { + setData(data); + return; + } + + setData(sortByColumnInPlace([...data], columnIndex, sortOrder)); +} + +function getColumnsWithSortOrder(columns: readonly Column[], columnIndex: number, sortOrder: SortOrder) { + return columns.map((column, i) => ({ + ...column, + sortOrder: i === columnIndex ? sortOrder : column.sortOrder ? 'none' : undefined, + })); +} + +function sortByColumnInPlace(data: T[], columnIndex: number, sortOrder: SortOrder) { + data.sort((a, b) => { + const aVal = Object.values(a)[columnIndex]; + const bVal = Object.values(b)[columnIndex]; + + if (priorities.includes(aVal as Priority) && priorities.includes(bVal as Priority)) { + const aI = priorities.indexOf(aVal as Priority); + const bI = priorities.indexOf(bVal as Priority); + return sortOrder === 'ascending' ? aI - bI : bI - aI; + } + + if ( + (typeof aVal === 'string' || typeof aVal === 'number') && + (typeof bVal === 'string' || typeof bVal === 'number') + ) { + if (aVal < bVal) return sortOrder === 'ascending' ? -1 : 1; + if (aVal > bVal) return sortOrder === 'ascending' ? 1 : -1; + } + + return 0; + }); + return data; +} + +interface Issue { + id: string; + priority: Priority; + votes: number; +} + +const random = createRandom(2655435721n); + +const issuesLongData: readonly Issue[] = Array.from({length: 100_000}, (_, i) => { + const prefix = issuePrefix(random); + const id = `${prefix}-${i}`; + const votes = random(1000); + const priority = random(priorities); + return {id, priority, votes}; +}); + +function issuePrefix(r: ReturnType) { + const aCode = 'A'.codePointAt(0)!; + const firstLetter = String.fromCharCode(aCode + r(26)); + const secondLetter = String.fromCharCode(aCode + r(26)); + return `${firstLetter}${secondLetter}`; +} + +const issuesColumns = [ + { + key: 'ID', + sortOrder: 'none', + renderCell: ({id}) => ( + + {id} + + ), + indent: true, + tdClassName: style.tdWithChevron, + }, + { + key: 'Priority', + sortOrder: 'none', + renderCell: ({priority}) => {priority}, + }, + { + key: 'votes', + name: 'Votes', + sortOrder: 'none', + }, +] satisfies Column[]; + +function priorityToTagType(priority: Priority): TagType | undefined { + if (priority === 'Trivial') return TagType.SUCCESS; + if (priority === 'Minor') return TagType.MAIN; + if (priority === 'Normal') return TagType.DEFAULT; + if (priority === 'Major') return TagType.WARNING; + if (priority === 'Critical') return TagType.ERROR; + if (priority === 'Blocker') return TagType.PURPLE; + return undefined; +} + +/** + * Disables docs for stories with long data, because Storybook freezes + * when trying to render a long list of items in the docs tab. + */ +const noDocsParams = { + docs: { + disable: true, + }, +}; + +export const WithVirtualization: TableStory = { + args: { + // Passing long data here would freeze the Storybook + data: [], + columns: issuesColumns, + getKey, + }, + + render(args) { + const [data, setData] = useState(issuesLongData); + const [columns, setColumns] = useState(args.columns); + + return ( +
+ sortByColumn(issuesLongData, columns, columnIndex, newOrder, setData, setColumns) + } + virtualizeRows + /> + ); + }, + + parameters: { + ...noDocsParams, + screenshots: {skip: true}, + }, + + tags: ['!autodocs'], +}; + +/** + * Screenshot tests are unstable on long datasets + */ +const issuesLongDataSlice: readonly Issue[] = issuesLongData.slice(0, 150); + +function virtualizedScrollerActions(initialScrollY: number) { + return [ + {type: 'wait', delay: 600}, + {type: 'scroll', selector: '[data-table-scroller]', x: 0, y: initialScrollY}, + {type: 'wait', delay: 300}, + { + type: 'executeJS', + script: ` + const scroller = document.querySelector('[data-table-scroller]'); + const {top: scrollerTop, bottom: scrollerBottom} = scroller.getBoundingClientRect(); + const lookaheadWindowTop = scrollerTop - 400; + const lookaheadWindowBottom = scrollerBottom + 400; + + const firstMaterialized = scroller.querySelector('tbody tr[data-from]:first-child + tr'); + const {top: firstMaterializedTop, bottom: firstMaterializedBottom} = firstMaterialized.getBoundingClientRect(); + + const lastMaterialized = scroller.querySelector('tbody tr:has(+tr[data-from]:last-child)'); + const {top: lastMaterializedTop, bottom: lastMaterializedBottom} = lastMaterialized.getBoundingClientRect(); + + if (!(firstMaterializedTop <= lookaheadWindowTop && lookaheadWindowTop <= firstMaterializedBottom)) { + throw new Error('First materialized row must cross the lookahead window top, but: lookaheadWindowTop=' + lookaheadWindowTop + ', firstMaterializedTop=' + firstMaterializedTop + ', firstMaterializedBottom=' + firstMaterializedBottom); + } + + if (!(lastMaterializedTop <= lookaheadWindowBottom && lookaheadWindowBottom <= lastMaterializedBottom)) { + throw new Error('Last materialized row must cross the lookahead window bottom, but: lookaheadWindowBottom=' + lookaheadWindowBottom + ', lastMaterializedTop=' + lastMaterializedTop + ', lastMaterializedBottom=' + lastMaterializedBottom); + } + `, + }, + {type: 'scroll', selector: '[data-table-scroller]', x: 0, y: 200}, + {type: 'wait', delay: 300}, + { + type: 'executeJS', + script: ` + const scroller = document.querySelector('[data-table-scroller]'); + const {top: scrollerTop, bottom: scrollerBottom} = scroller.getBoundingClientRect(); + const retentionWindowTop = scrollerTop - 450; + + const firstMaterialized = scroller.querySelector('tbody tr[data-from]:first-child + tr'); + const {top: firstMaterializedTop, bottom: firstMaterializedBottom} = firstMaterialized.getBoundingClientRect(); + + if (!(firstMaterializedTop <= retentionWindowTop && retentionWindowTop <= firstMaterializedBottom)) { + throw new Error('First materialized row must cross the retention window top, but: retentionWindowTop=' + retentionWindowTop + ', firstMaterializedTop=' + firstMaterializedTop + ', firstMaterializedBottom=' + firstMaterializedBottom); + } + `, + }, + {type: 'capture', name: 'light', selector: '[data-table-scroller]'}, + ]; +} + +export const WithVirtualizationInScrollerTop: TableStory = { + args: { + data: [], + columns: issuesColumns, + getKey, + }, + + render(args) { + const [data, setData] = useState(issuesLongDataSlice); + const [columns, setColumns] = useState(args.columns); + const scrollerRef = useRef(null); + + return ( +
+
+ sortByColumn(issuesLongDataSlice, columns, columnIndex, newOrder, setData, setColumns) + } + virtualizeRows + scrollerRef={scrollerRef} + /> + + ); + }, + + parameters: { + ...noDocsParams, + screenshots: { + actions: virtualizedScrollerActions(1500), + }, + }, + + tags: ['!autodocs'], +}; + +export const WithVirtualizationInScrollerBottom: TableStory = { + args: { + data: [], + columns: issuesColumns, + getKey: ({id}) => id, + }, + + render(args) { + const scrollerRef = useRef(null); + + return ( +
+
+ + ); + }, + + parameters: { + ...noDocsParams, + screenshots: { + actions: virtualizedScrollerActions(3000), + }, + }, + + tags: ['!autodocs'], +}; + +export const WithConditionalVirtualization: TableStory = { + args: { + data: [], + columns: issuesColumns, + getKey: ({id}) => id, + }, + + render(args) { + const [virtualizeRows, setVirtualizeRows] = useState(true); + const scrollerRef = useRef(null); + + return ( + <> + setVirtualizeRows(e.target.checked)} + /> +
+
+ + + ); + }, + + parameters: { + ...noDocsParams, + screenshots: {skip: true}, + }, + + tags: ['!autodocs'], +}; + +export const WithFocus: TableStory<(typeof smallDataSlice)[number]> = { + args: { + data: smallDataSlice, + columns: [ + {key: 'id', name: 'ID'}, + {key: 'country', name: 'Country'}, + {key: 'city', name: 'City'}, + { + key: 'URL', + renderCell: ({url}) => , + tdClassName: style.tdUrl, + }, + ], + getKey, + }, + + render(args) { + return ( +
( + { + if (!isWithinInteractiveElement(e.target)) { + focusWithTemporaryTabIndex(e.currentTarget); + e.preventDefault(); + } + }} + /> + )} + /> + ); + }, + + parameters: { + screenshots: { + actions: [ + {type: 'waitForElementToShow', selector: 'a[data-test~="table-focus-link"]'}, + {type: 'focus', selector: 'a[data-test~="table-focus-link"]'}, + {type: 'keys', value: ['ArrowDown']}, + ...waitAndCapture, + ], + }, + }, +}; + +interface IssueNode extends Issue { + children?: IssueNode[]; +} + +const issueTreeRoot: IssueNode = (function genNode(level: number, counter: {value: number}): IssueNode { + const isRoot = level === 0; + + const id = isRoot ? '_root' : `${issuePrefix(random)}-${counter.value++}`; + const priority = isRoot ? 'Normal' : random(priorities); + const votes = isRoot ? -1 : random(1000); + const childrenLength = isRoot ? 10 : level > 3 ? 0 : random(4); + + return { + id, + priority, + votes, + children: Array.from({length: childrenLength}, () => genNode(level + 1, counter)), + }; +})(0, {value: 0}); + +function deepCopy({children, ...node}: IssueNode): IssueNode { + return { + ...node, + ...(children ? {children: children.map(deepCopy)} : {}), + }; +} + +function getNodeByPath(current: IssueNode | undefined, path: number[]): IssueNode | undefined { + if (!current) return undefined; + const [index, ...tail] = path; + + const node = current.children?.[index]; + if (!node) return undefined; + if (!tail.length) return node; + + return getNodeByPath(node, tail); +} + +function isChildPath(parent: number[], child: number[]) { + if (parent.length >= child.length) return false; + return parent.every((num, i) => num === child[i]); +} + +interface IssueFlat extends Issue { + hasChildren: boolean; + path: number[]; +} + +function isExpanded(data: readonly IssueFlat[], index: number) { + const item = data[index]; + const nextItem = data[index + 1]; + return item?.hasChildren && nextItem && isChildPath(item.path, nextItem.path); +} + +export const WithExpandAndFocus: TableStory = { + render() { + const [treeData, setTreeData] = useState(() => deepCopy(issueTreeRoot)); + + const [flatData, setFlatData] = useState(() => + issueTreeRoot.children!.map((item, index) => ({ + id: item.id, + priority: item.priority, + votes: item.votes, + hasChildren: !!item.children?.length, + path: [index], + })), + ); + + const handleExpand = useEffectEvent((item: IssueFlat, index: number, action: 'expand' | 'collapse' | 'toggle') => { + const isExpandedNow = isExpanded(flatData, index); + if (isExpandedNow && action !== 'expand') { + // Collapse + setFlatData(flatData.filter(it => !isChildPath(item.path, it.path))); + } else if (!isExpandedNow && action !== 'collapse') { + // Expand + const itemChildren = getNodeByPath(treeData, item.path)?.children?.map(({children, ...child}, i) => ({ + ...child, + path: [...item.path, i], + hasChildren: !!children?.length, + })); + if (itemChildren?.length) { + const newData = [...flatData]; + newData.splice(index + 1, 0, ...itemChildren); + setFlatData(newData); + } + } + }); + + const [idColumn, ...restColumns] = issuesColumns; + const [columns, setColumns] = useState[]>(() => [ + { + ...idColumn, + renderCell: (item, index, items) => { + const expanded = isExpanded(items, index); + return ( + <> + {item.hasChildren && ( + + )}{' '} + + {idColumn.renderCell?.(item)} + + + ); + }, + tdClassName: () => style.tdWithChevron, + }, + ...restColumns, + ]); + + const [selection, setSelection] = useState(() => new TableSelection({data: flatData})); + + const handleSort = useEffectEvent((columnIndex: number, sortOrder: SortOrder) => { + const newTreeData = deepCopy(issueTreeRoot); + if (sortOrder !== 'none') { + (function sortNodeInPlace(node: IssueNode) { + if (node.children?.length) { + sortByColumnInPlace(node.children, columnIndex, sortOrder); + node.children.forEach(sortNodeInPlace); + } + })(newTreeData); + } + + const newFlatData: IssueFlat[] = []; + const visibleIssuesIds = new Set(flatData.map(item => item.id)); + (function collectToFlatItems(node, currentPath: number[]) { + if (visibleIssuesIds.has(node.id)) { + newFlatData.push({ + id: node.id, + priority: node.priority, + votes: node.votes, + hasChildren: !!node.children?.length, + path: currentPath, + }); + } + node.children?.forEach((child, index) => collectToFlatItems(child, [...currentPath, index])); + })(newTreeData, []); + + setColumns(getColumnsWithSortOrder(columns, columnIndex, sortOrder)); + setTreeData(newTreeData); + setFlatData(newFlatData); + }); + + return ( +
id} + onSort={handleSort} + renderItem={(item, i) => ( + { + if (isWithinInteractiveElement(e.target)) return; + + focusWithTemporaryTabIndex(e.currentTarget); + + handleExpand(item, i, 'toggle'); + setSelection(selection.focus(item)); + + e.preventDefault(); + }} + onKeyDown={e => { + if (document.activeElement === e.currentTarget) { + const action = + e.key === ' ' || e.key === 'Enter' + ? 'toggle' + : e.key === 'ArrowLeft' + ? 'collapse' + : e.key === 'ArrowRight' + ? 'expand' + : undefined; + if (action) { + handleExpand(item, i, action); + e.preventDefault(); + } + } + }} + /> + )} + /> + ); + }, + + parameters: { + screenshots: { + actions: [ + {type: 'click', selector: 'thead th:nth-child(2) button'}, + {type: 'click', selector: 'thead th:nth-child(2) button'}, + ...waitAndCapture, + ], + }, + }, +}; + +export const NoHeader: TableStory<(typeof smallDataSlice)[number]> = { + args: { + data: smallDataSlice, + columns: [ + {key: 'id', name: 'ID'}, + {key: 'Country', renderCell: ({country}) => country}, + {key: 'City', renderCell: ({city}) => city}, + ], + getKey, + }, + + render(args) { + return ( +
+ ); + }, + + parameters: { + screenshots: {skip: true}, + }, +}; + +export const WithColumnReorder: TableStory<(typeof smallDataSlice)[number]> = { + args: { + data: smallDataSlice, + columns: [ + { + key: 'ID', + canReorder: true, + renderCell: ({id}) => id, + }, + { + key: 'Country', + sortOrder: 'none', + canReorder: true, + renderCell: ({country}) => country, + }, + { + key: 'City', + sortOrder: 'none', + canReorder: true, + renderCell: ({city}) => city, + }, + { + key: 'URL', + canReorder: true, + renderCell: ({url}) => , + tdClassName: style.tdUrl, + }, + ], + getKey, + }, + + render(args) { + const [data, setData] = useState(args.data); + const [columns, setColumns] = useState(args.columns); + return ( +
+ sortByColumn(args.data, columns, columnIndex, sortOrder, setData, setColumns) + } + onColumnReorder={(fromIndex, insertionIndex) => reorderColumns(columns, fromIndex, insertionIndex, setColumns)} + columnEditButton + /> + ); + }, + + parameters: { + screenshots: {skip: true}, + }, + + tags: ['!autodocs'], +}; + +function reorderColumns( + columns: readonly Column[], + fromIndex: number, + insertionIndex: number, + setColumns: (newColumns: readonly Column[]) => void, +) { + const [...newColumns] = columns; + const [moved] = newColumns.splice(fromIndex, 1); + newColumns.splice(fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex, 0, moved); + setColumns(newColumns); +} + +export const WithColumnReorderLongSticky: TableStory = { + args: { + data: [], + columns: [ + { + key: 'ID', + canReorder: true, + renderCell: ({id}) => ( + + {id} + + ), + indent: true, + }, + { + key: 'Priority', + canReorder: true, + renderCell: ({priority}) => {priority}, + }, + { + key: 'Votes', + canReorder: true, + renderCell: ({votes}) => votes, + }, + ] satisfies Column[], + getKey: ({id}) => id, + }, + + render(args) { + const [data] = useState(() => issuesLongData.slice(0, 200)); + const [columns, setColumns] = useState(args.columns); + + return ( +
reorderColumns(columns, fromIndex, insertionIndex, setColumns)} + columnEditButton + /> + ); + }, + + parameters: { + ...noDocsParams, + screenshots: {skip: true}, + }, + + tags: ['!autodocs'], +}; + +interface Build { + id: number; + branch: string; + status: 'success' | 'failed' | 'running' | 'pending'; + agent: string; + triggeredBy: string; + triggered: Date; + started: Date | undefined; + finished: Date | undefined; + problems: string[]; + selected?: boolean; + expanded?: boolean; + favorite?: boolean; +} + +const teamCityBuilds = Array.from({length: 500}, (_, i): Build => { + const id = i; + const branch = random([ + 'main', + `develop-${random(2, 10)}.0`, + `release-${random(2, 10)}.0`, + `feature/${issuePrefix(random)}-${random(10000, 20000)}`, + ]); + const status = random(['success', 'failed', 'running', 'pending'] as const); + const agent = `${random(['linux', 'windows', 'macos'])}-${random([4, 8, 16])}gb-agent-${random(100_000_000_000)}`; + const triggeredBy = `${random(['Alice', 'Bob', 'Charlie', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'])} ${random(['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Garcia', 'Rodriguez', 'Wilson'])}`; + const triggered = random(new Date(2025, 3, 20), new Date(2025, 4, 1)); + const started = status !== 'pending' ? random(triggered, addHours(triggered, 10)) : undefined; + const finished = started && status !== 'running' ? random(started, addHours(started, 20)) : undefined; + const problems = + status === 'failed' + ? random( + [ + `Build process exited with code ${random(1, 100)}`, + `Linter found ${random(1, 20)} errors`, + `${random(2, 50)} tests failed`, + `${random(2, 10)} dependency vulnerabilities found`, + `Timeout while waiting for response from service: ${random(1, 10)}000 ms`, + `Insufficient disk space on agent ${agent}`, + `Failed to download ${random(2, 10)} artifacts`, + `Error parsing configuration file at line ${random(1, 200)}`, + ], + random(1, 6), + ) + : []; + + return {id, branch, status, agent, triggeredBy, triggered, started, finished, problems}; +}); + +export const TeamCityBuildsSticky: TableStory = { + name: 'TeamCity Builds Sticky', + + render() { + const [data, setData] = useState(() => [...teamCityBuilds] as const); + const [columnEditing, setColumnEditing] = useState(false); + + const dateShortFmt = 'dd MMM yy HH:mm'; + const [columns, setColumns] = useState[]>(() => [ + { + key: 'Chevron', + renderHeader: () => Expand/Collapse, + renderCell: (item, index, items) => ( +
+ +
+ ), + thClassName: style.chevronTh, + tdClassName: style.chevronTd, + }, + { + key: 'Checkbox', + renderHeader: () => Select, + renderCell: (item, index, items) => ( +
+ setData(items.with(index, {...item, selected: e.target.checked}))} + aria-label={`Select build ${item.id}`} + /> + +
+ ), + thClassName: style.checkboxTh, + }, + { + key: 'Id', + canReorder: i => i > 1, + renderCell: ({id}) => ( +
+ + #{id} + +
+ ), + thClassName: style.idTh, + }, + { + key: 'Branch', + canReorder: i => i > 1, + deletable: true, + renderCell: ({branch}) =>
{branch}
, + }, + { + key: 'Status', + canReorder: i => i > 1, + deletable: true, + renderCell: ({status}) => ( +
+ + {status} + +
+ ), + }, + { + key: 'Agent', + canReorder: i => i > 1, + deletable: true, + renderCell: ({agent}) =>
{agent}
, + }, + { + key: 'Started', + canReorder: i => i > 1, + deletable: true, + renderCell: ({started}) =>
{started ? format(started, dateShortFmt) : '—'}
, + }, + ]); + + return ( + <> +
+ setColumnEditing(e.target.checked)} + label='Column editing mode' + /> +
+
id} + stickyHeader + renderItem={(item, index, items) => ( + + )} + onColumnReorder={(fromIndex, insertionIndex) => + reorderColumns(columns, fromIndex, insertionIndex, setColumns) + } + onColumnDelete={columnIndex => setColumns(columns.filter((_, i) => i !== columnIndex))} + virtualizeRows + estimateHeight={item => { + let h = 40; + if (item.expanded) h += 147; + if (item.problems.length) h += 6 + item.problems.length * 20; + return h; + }} + columnEditing={columnEditing} + onColumnEditingRequest={setColumnEditing} + /> + + ); + }, + + parameters: { + ...noDocsParams, + screenshots: {skip: true}, + }, + + tags: ['!autodocs'], +}; + +function TeamCityBuild({ + build, + build: {triggeredBy, triggered, started, finished, problems, expanded}, + index, + builds, + columnsNumber, + setData, +}: { + build: Build; + index: number; + builds: readonly Build[]; + columnsNumber: number; + setData: (newData: readonly Build[]) => void; +}) { + const dateLongFmt = 'dd MMM yyyy HH:mm:ss'; + + const mainRef = useRef(null); + const detailsRef = useRef(null); + + useItemVirtualization({ + index, + refs: useMemo(() => (expanded ? [mainRef, detailsRef] : mainRef), [expanded]), + onIntersectionChange: useCallback( + (isIntersecting, _i, elements) => + isIntersecting.every(it => it === false) && + elements.every(el => el?.isConnected) && + [ + elements[0]?.previousElementSibling?.previousElementSibling, + elements[0]?.previousElementSibling, + ...elements, + elements.at(-1)?.nextElementSibling, + elements.at(-1)?.nextElementSibling?.nextElementSibling, + ].every(el => !el?.contains(document.activeElement)) + ? elements.reduce((h, el) => h + el!.getBoundingClientRect().height, 0) + : undefined, + [], + ), + }); + + const columnAnimation = use(ColumnAnimationContext); + const columnAnimationEmulatorRef = useRef(null); + + useEffect(() => { + const columnAnimationEmulator = columnAnimationEmulatorRef.current; + const table = mainRef.current?.closest('table'); + + if (!columnAnimationEmulator || !table) return; + + if (columnAnimation?.phase === 'initial') { + const {columnIndex} = columnAnimation; + const th = table.querySelector(`th:nth-child(${columnIndex + 1})`); + if (th) { + const tableLeft = table.getBoundingClientRect().left; + const thRect = th.getBoundingClientRect(); + columnAnimationEmulator.style.left = `${thRect.left - tableLeft}px`; + columnAnimationEmulator.style.width = `${thRect.width}px`; + } + } else if (!columnAnimation) { + columnAnimationEmulator.style.removeProperty('left'); + columnAnimationEmulator.style.removeProperty('width'); + } + }, [columnAnimation]); + + return ( + <> + { + if (!isWithinInteractiveElement(e.target)) { + focusWithTemporaryTabIndex(e.currentTarget); + setData(builds.with(index, {...build, expanded: !build.expanded})); + e.preventDefault(); + } + }} + onKeyDown={e => { + if ( + document.activeElement === e.currentTarget && + (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') + ) { + const newExpanded = e.key === 'ArrowLeft' ? false : e.key === 'ArrowRight' ? true : !build.expanded; + setData(builds.with(index, {...build, expanded: newExpanded})); + e.preventDefault(); + } + }} + /> + {build.expanded && ( + { + if ( + document.activeElement === e.currentTarget && + (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowLeft') + ) { + setData(builds.with(index, {...build, expanded: false})); + focusWithTemporaryTabIndex(e.currentTarget.previousElementSibling! as HTMLTableRowElement); + e.preventDefault(); + } + }} + > + +
+ + +
+ Array.isArray(value) ? ( +
    + {value.map(problem => ( +
  • {problem}
  • + ))} +
+ ) : ( + value + ), + }, + ]} + getKey={([property]) => property} + noHeader + aria-label='Build details' + /> +
+ + + )} + + ); +} + +const listenersMap = new WeakMap void>>(); + +/** + * Only observes setting an item by index, e.g. array[i] = {...array[i], newProp: newValue}. + */ +function observable(array: T[]): T[] { + array.forEach(item => { + listenersMap.set(item, new Set<() => void>()); + }); + + return new Proxy(array, { + set(target, key, value, receiver) { + const prev = Reflect.get(target, key, receiver); + const success = Reflect.set(target, key, value, receiver); + const listeners = listenersMap.get(prev); + if (success && listeners) { + listeners.forEach(l => l()); + listenersMap.set(value, listeners); + } + return success; + }, + }); +} + +function observer

(Component: ComponentType

) { + return function Observer(props: P) { + const [, rerender] = useReducer(x => x + 1, 0); + + for (const prop of Object.values(props)) { + if (prop && typeof prop === 'object' && listenersMap.has(prop)) { + listenersMap.get(prop)!.add(rerender); + } + } + + return ; + }; +} + +const smallDataWithSelected = smallDataSlice.map(item => ({...item, selected: false})); + +export const MobXCase: TableStory<(typeof smallDataWithSelected)[number]> = { + name: 'MobX case', + + args: { + data: smallDataWithSelected, + }, + + render(args) { + const data = useMemo(() => observable([...args.data]), [args.data]); + + const renderCounter = useMemo( + () => ({ + val: 0, + }), + [], + ); + + const columns = useMemo( + () => + [ + { + key: 'ID', + renderCell: (item, index) => ( + { + data[index] = {...item, selected: e.target.checked}; + e.stopPropagation(); + }} + label={String(item.id)} + /> + ), + }, + {key: 'country', name: 'Country'}, + {key: 'city', name: 'City'}, + { + key: 'URL', + renderCell: ({url}) => , + tdClassName: style.tdUrl, + }, + {key: 'Render counter', renderCell: () => renderCounter.val++}, + ] satisfies Column<(typeof smallDataWithSelected)[number]>[], + [data, renderCounter], + ); + + const CountryItemObserver = useMemo( + () => + observer(function CountryItemRenderer({ + item: _initialItem, + index, + }: { + item: (typeof smallDataWithSelected)[number]; + index: number; + }) { + const item = data[index]; + + return ( + { + if (!isWithinInteractiveElement(e.target)) { + data[index] = {...item, selected: !item.selected}; + e.preventDefault(); + } + }} + /> + ); + }), + [data], + ); + + return ( +

id} + renderItem={(item, index) => } + /> + ); + }, + + parameters: { + screenshots: { + actions: [ + {type: 'click', selector: 'tbody tr:nth-child(2) td:nth-child(1) input[type="checkbox"]'}, + {type: 'click', selector: 'tbody tr:nth-child(4) td:nth-child(2)'}, + ...waitAndCapture, + ], + }, + }, +}; + +export const SimpleRerenderTest: TableStory<(typeof smallDataWithSelected)[number]> = { + args: { + data: [...smallDataWithSelected], + }, + + render(args) { + const [data, setData] = useState(args.data); + + const renderCounter = useMemo( + () => ({ + val: 0, + }), + [], + ); + + const columns = useMemo( + () => + [ + { + key: 'ID', + renderCell: (item, index) => ( + { + setData(data.with(index, {...item, selected: e.target.checked})); + e.preventDefault(); + }} + label={String(item.id)} + /> + ), + }, + {key: 'country', name: 'Country'}, + {key: 'city', name: 'City'}, + { + key: 'URL', + renderCell: ({url}) => , + tdClassName: style.tdUrl, + }, + {key: 'Render counter', renderCell: () => renderCounter.val++}, + ] satisfies Column<(typeof smallDataWithSelected)[number]>[], + [data, renderCounter], + ); + + return ( +
id} + renderItem={(item, index) => ( + { + if (!isWithinInteractiveElement(e.target)) { + setData(data.with(index, {...item, selected: !item.selected})); + e.preventDefault(); + } + }} + /> + )} + /> + ); + }, + + parameters: { + screenshots: {skip: true}, + }, + + tags: ['!autodocs'], +}; diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx new file mode 100644 index 00000000000..7199cc2f52f --- /dev/null +++ b/src/table/table.test.tsx @@ -0,0 +1,356 @@ +/* eslint-disable no-nested-ternary */ +import {useMemo, useState} from 'react'; +import {fireEvent, render, screen} from '@testing-library/react'; + +import Table from './table'; +import {DefaultItemRenderer} from './default-item-renderer'; + +import type {Column} from './table-props'; + +interface CountryItem { + id: number; + country: string; + capital: string; + wikipedia: string; +} + +const countries: CountryItem[] = [ + {id: 8421, country: 'Norway', capital: 'Oslo', wikipedia: 'https://en.wikipedia.org/wiki/Norway'}, + {id: 1735, country: 'Sweden', capital: 'Stockholm', wikipedia: 'https://en.wikipedia.org/wiki/Sweden'}, + {id: 6098, country: 'Ireland', capital: 'Dublin', wikipedia: 'https://en.wikipedia.org/wiki/Ireland'}, + {id: 2954, country: 'Netherlands', capital: 'Amsterdam', wikipedia: 'https://en.wikipedia.org/wiki/Netherlands'}, + {id: 7186, country: 'Germany', capital: 'Berlin', wikipedia: 'https://en.wikipedia.org/wiki/Germany'}, + {id: 3842, country: 'Belgium', capital: 'Brussels', wikipedia: 'https://en.wikipedia.org/wiki/Belgium'}, + {id: 9520, country: 'Austria', capital: 'Vienna', wikipedia: 'https://en.wikipedia.org/wiki/Austria'}, + {id: 4671, country: 'France', capital: 'Paris', wikipedia: 'https://en.wikipedia.org/wiki/France'}, + {id: 1207, country: 'Italy', capital: 'Rome', wikipedia: 'https://en.wikipedia.org/wiki/Italy'}, + {id: 5319, country: 'Spain', capital: 'Madrid', wikipedia: 'https://en.wikipedia.org/wiki/Spain'}, + {id: 8043, country: 'Portugal', capital: 'Lisbon', wikipedia: 'https://en.wikipedia.org/wiki/Portugal'}, +]; + +const getKey = ({id}: CountryItem) => id; + +const baseColumns: Column[] = [ + {key: 'id', name: 'Id', sortOrder: 'none', deletable: true, canReorder: true}, + {key: 'country', name: 'Country', sortOrder: 'none', deletable: true, canReorder: true}, + {key: 'capital', name: 'Capital', sortOrder: 'none', deletable: true, canReorder: true}, + { + key: 'wikipedia', + name: 'Wikipedia', + deletable: true, + canReorder: true, + renderCell: item => ( + + {`${item.country} in Wikipedia`} + + ), + }, +]; + +describe('Table basic scenarios', () => { + it('has unique ids', () => { + const ids = countries.map(getKey); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).to.equal(ids.length); + }); + + it('renders 10 rows', () => { + const {container} = render(
); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).to.have.length(countries.length); + expect(rows[1].textContent).to.contain('Sweden'); + expect(rows[9].textContent).to.contain('Madrid'); + }); + + it('supports multi-selection with checkboxes and selected-row className change', () => { + function MultiselectTable() { + interface SelectableCountryItem extends CountryItem { + selected: boolean; + } + + const [data, setData] = useState(() => + countries.map(item => ({...item, selected: false})), + ); + + const columns = useMemo[]>( + () => [ + { + key: 'select', + name: 'Select', + renderCell: (item, index) => ( + { + setData(currentData => currentData.with(index, {...item, selected: e.target.checked})); + }} + /> + ), + }, + ...(baseColumns as unknown as Column[]), + ], + [], + ); + + return ( +
} + /> + ); + } + + render(); + + fireEvent.click(screen.getByRole('checkbox', {name: 'Select Brussels'})); + fireEvent.click(screen.getByRole('checkbox', {name: 'Select Madrid'})); + + expect((screen.getByRole('checkbox', {name: 'Select Brussels'}) as HTMLInputElement).checked).to.equal(true); + expect((screen.getByRole('checkbox', {name: 'Select Madrid'}) as HTMLInputElement).checked).to.equal(true); + + const brusselsRow = screen.getByText('Brussels').closest('tr'); + const madridRow = screen.getByText('Madrid').closest('tr'); + const berlinRow = screen.getByText('Berlin').closest('tr'); + + expect(brusselsRow).to.not.equal(null); + expect(madridRow).to.not.equal(null); + expect(berlinRow).to.not.equal(null); + + const brusselsClassName = brusselsRow!.className; + const madridClassName = madridRow!.className; + const berlinClassName = berlinRow!.className; + + expect(brusselsClassName).to.equal(madridClassName); + expect(brusselsClassName).not.to.equal(berlinClassName); + }); + + it('renders a table without a header', () => { + const {container} = render(
); + expect(container.querySelector('thead')).to.equal(null); + expect(container.querySelectorAll('tbody tr')).to.have.length(countries.length); + }); + + it('moves focus to the previous row when ArrowUp is pressed on a link and keyboardFocusable is enabled', () => { + render( +
} + />, + ); + + const franceLink = screen.getByRole('link', {name: 'France in Wikipedia'}); + franceLink.focus(); + fireEvent.keyDown(franceLink, {key: 'ArrowUp'}); + + const viennaRow = screen.getByText('Vienna').closest('tr'); + expect(viennaRow).to.not.equal(null); + expect(document.activeElement).to.equal(viennaRow); + }); + + it('does not move focus to the previous row when Arrow is pressed on an editable element', () => { + const {container} = render( +
, + }, + ]} + getKey={getKey} + renderItem={(_item, index) => } + />, + ); + const someInput = container.querySelector('tr:nth-child(4) input'); + expect(someInput).to.not.equal(null); + (someInput as HTMLElement).focus(); + fireEvent.keyDown(someInput as HTMLElement, {key: 'ArrowUp'}); + expect(document.activeElement).to.equal(someInput); + }); + + it('sorts by one column in descending order and then by another column', () => { + function SortableTable() { + const [data, setData] = useState(countries); + const [columns, setColumns] = useState(baseColumns); + + return ( +
{ + setColumns(prevColumns => + prevColumns.map((column, i) => ({ + ...column, + sortOrder: i === columnIndex ? sortOrder : column.sortOrder ? 'none' : undefined, + })), + ); + + const columnKey = String(columns[columnIndex].key) as keyof CountryItem; + setData(previousData => + [...previousData].sort((a, b) => { + const aVal = a[columnKey]; + const bVal = b[columnKey]; + if (aVal < bVal) return sortOrder === 'ascending' ? -1 : 1; + if (aVal > bVal) return sortOrder === 'ascending' ? 1 : -1; + return 0; + }), + ); + }} + /> + ); + } + + const {container} = render(); + + fireEvent.click(screen.getByRole('button', {name: 'Country'})); + fireEvent.click(screen.getByRole('button', {name: 'Country'})); + + function getColumnTexts(columnIndex: number) { + const rows = [...container.querySelectorAll('tbody tr')]; + return rows.map(row => row.querySelector(`td:nth-child(${columnIndex + 1})`)?.textContent?.trim() ?? ''); + } + + expect(getColumnTexts(1)).to.deep.equal([ + 'Sweden', + 'Spain', + 'Portugal', + 'Norway', + 'Netherlands', + 'Italy', + 'Ireland', + 'Germany', + 'France', + 'Belgium', + 'Austria', + ]); + + const capitalSortButton = screen.getByRole('button', {name: 'Capital'}); + fireEvent.click(capitalSortButton); + + expect(getColumnTexts(2)).to.deep.equal([ + 'Amsterdam', + 'Berlin', + 'Brussels', + 'Dublin', + 'Lisbon', + 'Madrid', + 'Oslo', + 'Paris', + 'Rome', + 'Stockholm', + 'Vienna', + ]); + }); + + it('deletes a column from the header', () => { + function DeletableColumnsTable() { + const [columns, setColumns] = useState(baseColumns); + return ( +
{ + setColumns(previousColumns => previousColumns.filter((_, i) => i !== columnIndex)); + }} + /> + ); + } + + const {container} = render(); + + fireEvent.click(screen.getByRole('button', {name: 'Delete column Capital.'})); + + const headers = [...container.querySelectorAll('thead th')].map(th => th.textContent?.trim()); + expect(headers).to.deep.equal(['Id', 'Country', 'Wikipedia']); + }); + + it('moves a column to the right with keyboard on reorder handle', () => { + function ReorderableColumnsTable() { + const [columns, setColumns] = useState(baseColumns); + + return ( +
{ + setColumns(previousColumns => { + const newColumns = [...previousColumns]; + const [moved] = newColumns.splice(fromIndex, 1); + const nextIndex = fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex; + newColumns.splice(nextIndex, 0, moved); + return newColumns; + }); + }} + /> + ); + } + + const {container} = render(); + + const countryReorderButton = screen.getByRole('button', {name: 'Reorder column Country.'}); + countryReorderButton.focus(); + fireEvent.keyDown(countryReorderButton, {key: 'ArrowRight'}); + + const headers = [...container.querySelectorAll('thead th')].map(th => th.textContent?.trim()); + expect(headers).to.deep.equal(['Id', 'Capital', 'Country', 'Wikipedia']); + }); + + it('changes thead className when columnEditing is controlled externally', () => { + function ExternallyControlledColumnEditingTable() { + const [columnEditing, setColumnEditing] = useState(false); + + return ( + <> + +
+ + ); + } + + const {container} = render(); + + const thead = container.querySelector('thead'); + expect(thead).to.not.equal(null); + + const initialClassName = thead!.className; + + fireEvent.click(screen.getByRole('checkbox', {name: 'Toggle column editing'})); + + const updatedClassName = thead!.className; + expect(updatedClassName).to.not.equal(initialClassName); + }); + + it('changes thead className when using embedded columnEditButton', () => { + const {container} = render(
); + + const thead = container.querySelector('thead'); + expect(thead).to.not.equal(null); + + const initialClassName = thead!.className; + + fireEvent.click(screen.getByRole('button', {name: 'Show column controls.'})); + + const updatedClassName = thead!.className; + expect(updatedClassName).to.not.equal(initialClassName); + }); +}); diff --git a/src/table/table.tsx b/src/table/table.tsx new file mode 100644 index 00000000000..45f2f92efdf --- /dev/null +++ b/src/table/table.tsx @@ -0,0 +1,345 @@ +import React, {type ComponentPropsWithRef, Fragment, useCallback, useRef} from 'react'; +import classNames from 'classnames'; + +import {IntersectionObserverContext} from '../global/intersection-observer-context'; +import {CollapseItemIntoSpacerContext, SpacerRow, useVirtualItems, type VirtualItem} from './internal/virtual-items'; +import {DefaultItemRenderer} from './default-item-renderer'; +import {ColumnAnimationContext, defaultRowHeight, TablePropsContext} from './table-const'; +import {focusWithTemporaryTabIndex} from '../global/focus-with-temporary-tabindex'; +import {useColumnAnimation} from './internal/column-animation'; +import {useComposedRef} from '../global/compose-refs'; +import {TableHeader} from './internal/table-header'; +import {keyboardFocusableAttrName} from './table-primitives'; +import {isWithinNavigableElement} from '../global/is-within-navigable-element'; + +import type {TableProps} from './table-props'; + +import styles from './table.css'; + +/** + * Table component replacing the tables in the `legacy-table` folder. + * + * This documentation provides an overview of the most common usage patterns. + * See individual props and exported components for detailed behavior. + * + * ## Minimal usage + * + * You need the following props: + * - `data` + * - `getKey` + * - `columns` + * - `key` + * - `name` (optional but needed in most cases) + * - `renderCell` (optional but needed in most cases) + * + * ## Item rendering + * + * If `renderItem` is not specified, each item is rendered using + * `DefaultItemRenderer` (from `table/default-item-renderer`) + * as if the following code were used: + * + * ```tsx + *
( + * + * )} + * /> + * ``` + * + * `DefaultItemRenderer` renders a table row using the column definitions + * (`Column.renderCell`) and provides built-in support for features such as + * selection, keyboard navigation, and virtualization. It also accepts all + * standard `tr` attributes, including `ref`. + * + * Use `renderItem` to configure `DefaultItemRenderer` for each item: + * + * ```tsx + *
( + * handleClick(e, item, items)} + * /> + * )} + * /> + * ``` + * + * If you need complete control over rendering, `renderItem` can instead + * return your own table rows. See "Custom item rendering" below. + * + * ## Selection + * + * Selection is typically implemented using the following props + * of the `DefaultItemRenderer`: + * + * - `clickable` + * - `selected` + * - `onClick` or `onPointerUp`, etc. + * + * The following utilities (from `global`) may come in handy: + * + * - `TableSelection` class to manage selection state + * - An alternative approach is to keep a `selected` field on each item + * - `isWithinInteractiveElement()` to check if a click was made on a control + * or on "empty space" + * + * ```tsx + *
( + * { + * if (!isWithinInteractiveElement(e)) { + * setSelection(selection.toggleSelection(item)); + * } + * }} + * /> + * )} + * /> + * ``` + * + * Note that for accessibility reasons, you should have a cell with a checkbox + * to display and toggle item selection. + * + * ## Rows focus + * + * The table implements the ["roving tabindex"](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets#technique_1_roving_tabindex) + * technique to focus rows with the up/down arrow keys. + * Rows can also be focused on click or other pointer events. + * To support it, use the following props of the `DefaultItemRenderer`: + * + * - `keyboardFocusable` + * - `clickable`, if you want to react to hover + * - `onClick`, if you want to focus on click + * + * Useful utils: + * - `focusWithTemporaryTabIndex()` from `global` to focus a row temporarily + * patching its `tabindex`. + * + * ```tsx + *
( + * { + * if (!isWithinInteractiveElement(e)) { + * focusWithTemporaryTabIndex(e.currentTarget); + * } + * }} + * /> + * )} + * /> + * ``` + * + * Note that the table does not implement standard accessibility patterns such + * as `grid` or `treegrid`, so row focus is not announced by screen readers. + * Make sure all essential actions remain available without row focus, for + * example via standard Tab navigation. + * + * ## Sorting + * + * You need the following to support sorting: + * + * - Set `Column.sortOrder` to `'none'`, `'ascending'` or `'descending'` + * to render the sort button, `aria-sort`, and indicate the current + * sort order. + * - Handle `TableProps.onSort` callback in the client code. + * + * ## Deleting columns + * + * You need the following to support deleting columns: + * + * - Set `Column.deletable` to `true`. This will render a delete button in the + * column header. + * - Make sure the `column` has a proper `name` or `key` prop, which will be + * automatically included in the aria-label of the column delete button. + * - Handle `TableProps.onColumnDelete` callback in the client code. It is + * expected to update `columns` by removing the corresponding column. + * + * ## Moving columns + * + * - Set `Column.canReorder` to `true` or to predicate specifying possible + * insertion targets. + * This will render a reorder button in the column header. + * - Make sure the `column` has a proper `name` or `key` prop, which will be + * automatically included in the aria-label of the column reorder button. + * - Handle `TableProps.onColumnReorder` callback in the client code. It is + * expected to update `columns` by moving the corresponding column to the + * new position. + * + * ## Row virtualization + * + * To render only rows near the viewport while replacing off-screen rows with + * spacers, use: + * + * - `virtualizeRows` prop set to `true` + * - `scrollerRef` — required when the scrollable container is not the whole + * document + * - `estimateHeight` — recommended when rows are expected to be taller than + * the default height (e.g. multiline or custom content) + * - Fine-tuning props: `lookaheadPx`, `retentionMarginPx`, + * `minScrollAndResizeDeltaPx` + * + * ## Custom item rendering + * + * Use the `renderItem` prop to render an item in a completely custom way. + * The prop is expected to return one or more table rows for the item. + * Use `TableRow` and `TableCell` from `table/table-primitives` to apply + * the default row and cell styles. + * + * ### Focus + * + * Just like `DefaultItemRenderer`, `TableRow` accepts the + * `keyboardFocusable` prop. + * + * Focusable rows rendered by either component form a single keyboard + * navigation sequence. + * + * ### Virtualization + * + * If `Table.virtualizeRows` is set to `true`, you need to handle visibility + * for your custom-rendered component yourself with the + * `useItemVirtualization()` hook (from `table/item-virtualization`). The hook + * allows observing the intersection of one or multiple elements rendered for + * the item, and, based on their intersection status, reporting the item as + * eligible for virtualization. + * + * If you use `DefaultItemRenderer` as part of your custom row renderer, + * set the `noItemVirtualization` prop to `true`, otherwise it will also try + * to control the virtualization, possibly reporting incorrect item height. + * + * ### Column reorder animation + * + * Default-rendered rows highlight the column that was just reordered. To apply + * the same animation to your custom-rendered rows, use `ColumnAnimationContext` + * (from `table/table-const`) to get information about the currently animated column. + */ +export default function Table(props: TableProps & ComponentPropsWithRef<'table'>) { + const { + data, + columns, + getKey, + noHeader, + stickyHeader, + onSort, + onColumnDelete, + onColumnReorder, + noColumnReorderAnimation, + renderItem, + virtualizeRows = false, + scrollerRef, + estimateHeight = () => defaultRowHeight, + // eslint-disable-next-line no-magic-numbers + lookaheadPx = 400, + // eslint-disable-next-line no-magic-numbers + retentionMarginPx = 450, + // eslint-disable-next-line no-magic-numbers + minScrollAndResizeDeltaPx = 50, + columnEditing, + onColumnEditingRequest, + columnEditButton, + theadClassName, + theadTrClassName, + tbodyClassName, + + ref: userRef, + className, + ...restProps + } = props; + + const localRef = useRef(null); + + const {virtualItems, intersectionObserverHandle, collapseItemIntoSpacer} = useVirtualItems({ + enabled: virtualizeRows, + data, + scrollerRef, + tableRef: localRef, + estimateHeight, + lookaheadPx, + retentionMarginPx, + minScrollAndResizeDeltaPx, + }); + + const {columnAnimation, expectColumnReorder} = useColumnAnimation({ + disabled: noColumnReorderAnimation, + tableRef: localRef, + columns, + }); + + const handleRowNavigation = useCallback((e: React.KeyboardEvent) => { + if (e.defaultPrevented || isWithinNavigableElement(e.target)) return; + + const arrowUp = e.key === 'ArrowUp'; + const arrowDown = e.key === 'ArrowDown'; + if (!arrowUp && !arrowDown) return; + + const currentRow = (e.target as HTMLElement).closest('tr'); + if (currentRow?.parentElement?.parentElement !== localRef.current) { + return; + } + + let candidate: HTMLTableRowElement | null = currentRow; + while (candidate) { + candidate = ( + arrowUp ? candidate.previousElementSibling : candidate.nextElementSibling + ) as HTMLTableRowElement | null; + + if (candidate?.hasAttribute(keyboardFocusableAttrName)) { + focusWithTemporaryTabIndex(candidate); + e.preventDefault(); + return; + } + } + }, []); + + return ( + }> + +
+ + + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + + {(virtualizeRows ? virtualItems : data).map((item, index) => { + let dataItem: T; + let dataItemIndex: number; + + if (virtualizeRows) { + const virtualItem = item as VirtualItem; + if (virtualItem.type === 'spacer') { + return ; + } + + dataItemIndex = virtualItem.index; + if (dataItemIndex < 0 || dataItemIndex >= data.length) return null; + dataItem = data[dataItemIndex]; + } else { + dataItem = item as T; + dataItemIndex = index; + } + + return ( + + {renderItem ? ( + renderItem(dataItem, dataItemIndex, data) + ) : ( + + )} + + ); + })} + + + +
+ + + ); +} diff --git a/src/util-stories.ts b/src/util-stories.ts index 495dce92bd1..abe3619ba9f 100644 --- a/src/util-stories.ts +++ b/src/util-stories.ts @@ -1 +1,99 @@ +/* eslint-disable no-magic-numbers */ export const hideAddonsPanelParam = 'hideAddonsPanel'; + +export function createRandom(seed: bigint): { + /** + * Fractional in [0, 1) + */ + (): number; + /** + * Integer in [0, to) + */ + (to: number): number; + /** + * Integer in [from, to) + */ + (from: number, to: number): number; + /** + * Date in [from, to) + */ + (from: Date, to: Date): Date; + /** + * Up to random n items (in random order) from the array + */ + (array: T[], n: number): T[]; + /** + * Random item from the array + */ + (array: T[]): T; +} { + const u64Mask = 2n ** 64n - 1n; + const u64Range = 2n ** 64n; + const scrambleConst = 2685821657736338717n; + + let x = seed & u64Mask; + if (!x) throw new Error('Seed must be non-zero'); + + function nextFractional() { + x ^= x >> 12n; + x ^= (x << 25n) & u64Mask; + x ^= x >> 27n; + + const scrambled = (x * scrambleConst) & u64Mask; + return Number(scrambled) / Number(u64Range); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (...args: any[]): any => { + if (!args.length) { + return nextFractional(); + } + + if (args.length === 1) { + const [a] = args; + if (typeof a === 'number') { + return Math.floor(nextFractional() * a); + } + if (Array.isArray(a)) { + return a[Math.floor(nextFractional() * a.length)]; + } + } + + if (args.length === 2) { + const [a, b] = args; + + let fromNum: number | undefined; + let toNum: number | undefined; + let isDate = false; + + if (typeof a === 'number' && typeof b === 'number') { + fromNum = a; + toNum = b; + } + + if (a instanceof Date && b instanceof Date) { + fromNum = a.getTime(); + toNum = b.getTime(); + isDate = true; + } + + if (fromNum != null && toNum != null && fromNum < toNum) { + const r = Math.floor(nextFractional() * (toNum - fromNum)) + fromNum; + return isDate ? new Date(r) : r; + } + + if (Array.isArray(a) && typeof b === 'number') { + const aIndices = Array.from({length: a.length}, (_, i) => i); + const sample: unknown[] = []; + for (let i = 0; i < b && aIndices.length; i++) { + const indexIndex = Math.floor(nextFractional() * aIndices.length); + const [aIndex] = aIndices.splice(indexIndex, 1); + sample.push(a[aIndex]); + } + return sample; + } + } + + throw new Error(`Bad args: ${JSON.stringify(args)}`); + }; +} From 278b833b0605aa2511100127ca366c765e869fd1 Mon Sep 17 00:00:00 2001 From: JetBrains Ring UI Automation Date: Mon, 6 Jul 2026 07:57:15 +0000 Subject: [PATCH 07/36] 8.0.0-beta.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7742caee8ab..dde1afd98f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.3", + "version": "8.0.0-beta.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.3", + "version": "8.0.0-beta.4", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index 56576c17699..49606e35dac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.3", + "version": "8.0.0-beta.4", "description": "JetBrains UI library", "author": { "name": "JetBrains" From 646f3e22bed5f0f3f007ad6ffcafb3d274355139 Mon Sep 17 00:00:00 2001 From: Aleksei Berezkin <16083785+aleksei-berezkin@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:25:25 +0200 Subject: [PATCH 08/36] RG-2542 Table: item reorder with refined API (#9343) --- src/table/default-item-renderer.tsx | 35 +- src/table/internal/column-animation.ts | 91 ---- .../internal/reorder-animation-context.ts | 121 +++++ src/table/internal/reorder-handle.tsx | 468 ++++++++++++++++++ src/table/internal/reorder-layout-context.ts | 93 ++++ src/table/internal/table-header.tsx | 407 ++------------- .../{virtual-items.tsx => virtualization.tsx} | 31 +- src/table/item-virtualization.ts | 18 +- src/table/reorder-animation.ts | 47 ++ src/table/reorder-item-layout.ts | 44 ++ src/table/table-const.ts | 36 -- src/table/table-primitives.tsx | 50 ++ src/table/table-props.tsx | 49 +- src/table/table.css | 27 +- src/table/table.stories.css | 163 +++++- src/table/table.stories.tsx | 358 ++++++++++++-- src/table/table.test.tsx | 69 ++- src/table/table.tsx | 103 ++-- src/util-stories.ts | 4 +- 19 files changed, 1609 insertions(+), 605 deletions(-) delete mode 100644 src/table/internal/column-animation.ts create mode 100644 src/table/internal/reorder-animation-context.ts create mode 100644 src/table/internal/reorder-handle.tsx create mode 100644 src/table/internal/reorder-layout-context.ts rename src/table/internal/{virtual-items.tsx => virtualization.tsx} (91%) create mode 100644 src/table/reorder-animation.ts create mode 100644 src/table/reorder-item-layout.ts diff --git a/src/table/default-item-renderer.tsx b/src/table/default-item-renderer.tsx index fa39a26f66d..0536f7c6844 100644 --- a/src/table/default-item-renderer.tsx +++ b/src/table/default-item-renderer.tsx @@ -1,10 +1,12 @@ import {type ComponentPropsWithRef, type Context, type Key, use, useCallback, useRef} from 'react'; import classNames from 'classnames'; -import {ColumnAnimationContext, TablePropsContext} from './table-const'; +import {TablePropsContext} from './table-const'; import {useComposedRef} from '../global/compose-refs'; import {useItemVirtualization} from './item-virtualization'; import {TableCell, TableRow} from './table-primitives'; +import {ReorderAnimationContext} from './internal/reorder-animation-context'; +import {useReorderItemLayout} from './reorder-item-layout'; import type {TableProps} from './table-props'; @@ -48,6 +50,13 @@ export interface DefaultItemRendererProps { * and track the visibility yourself. */ noItemVirtualization?: boolean; + + /** + * When set to `true`, does not report the item's boundaries to the reorder system. + * Useful when you include `DefaultItemRenderer` as a part of a custom row renderer + * that registers boundaries itself. + */ + noReorderLayout?: boolean; } /** @@ -70,6 +79,7 @@ export function DefaultItemRenderer({ selected, level, noItemVirtualization, + noReorderLayout, ref: userRef, className, @@ -95,12 +105,24 @@ export function DefaultItemRenderer({ ), }); + useReorderItemLayout({ + disabled: noReorderLayout, + index, + getBounds: () => { + const r = localRef.current?.getBoundingClientRect(); + return {start: r?.top ?? 0, end: r?.bottom ?? 0}; + }, + }); + const tableProps = use(TablePropsContext as Context | null>); if (!tableProps) { return null; } - const animatedColumn = use(ColumnAnimationContext); + const {reorderAnimation} = use(ReorderAnimationContext); + const isAnimatedItem = reorderAnimation?.direction === 'items' && reorderAnimation.index === index; + const animatedColumnIndex = reorderAnimation?.direction === 'columns' ? reorderAnimation.index : undefined; + const animateClassName = reorderAnimation?.className; const {data, columns} = tableProps; const item = data[index]; @@ -111,7 +133,12 @@ export function DefaultItemRenderer({ {columns.map((column, columnIndex) => { @@ -121,7 +148,7 @@ export function DefaultItemRenderer({ 0 ? {paddingInlineStart: `${level * indentSize}px`} : undefined} diff --git a/src/table/internal/column-animation.ts b/src/table/internal/column-animation.ts deleted file mode 100644 index 211dd9f7b4e..00000000000 --- a/src/table/internal/column-animation.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'; - -import {parseCssDuration} from '../../global/parse-css-duration'; -import {requestAnimationFrameWithCleanup, setTimeoutWithCleanup} from '../../global/schedule-with-cleanup'; - -import type {Column} from '../table-props'; -import type {ColumnAnimation} from '../table-const'; - -import styles from '../table.css'; - -const reorderExpectationTimeout = 1000; - -export interface ReorderSpec { - fromIndex: number; - insertionIndex: number; -} - -export type ExpectColumnReorder = (reorderSpec: ReorderSpec) => void; - -export function useColumnAnimation({ - disabled, - tableRef, - columns, -}: { - disabled: boolean | undefined; - tableRef: RefObject; - columns: readonly Column[]; -}) { - const [columnAnimation, setColumnAnimation] = useState(null); - - const pendingColumnReorder = useRef[]}>(null); - - const expectColumnReorder = useCallback( - (reorderSpec: ReorderSpec) => { - if (disabled) return; - - const timerId = window.setTimeout(() => { - pendingColumnReorder.current = null; - }, reorderExpectationTimeout); - pendingColumnReorder.current = {...reorderSpec, timerId, columns}; - }, - [disabled, columns], - ); - - useEffect(() => { - return () => { - if (pendingColumnReorder.current) { - window.clearTimeout(pendingColumnReorder.current.timerId); - pendingColumnReorder.current = null; - } - }; - }, []); - - useEffect(() => { - const table = tableRef.current; - if (!table || !pendingColumnReorder.current || columns === pendingColumnReorder.current.columns) return; - - const {fromIndex, insertionIndex} = pendingColumnReorder.current; - pendingColumnReorder.current = null; - - const columnIndex = fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex; - return requestAnimationFrameWithCleanup(() => - setColumnAnimation(prev => - prev == null ? {columnIndex, phase: 'initial', cellClassName: styles.animatedColumnInitial} : prev, - ), - ); - }, [columns, tableRef]); - - useEffect(() => { - if (columnAnimation?.phase === 'initial') { - return requestAnimationFrameWithCleanup(() => - setColumnAnimation(prev => - prev === columnAnimation ? {...prev, phase: 'fade-out', cellClassName: styles.animatedColumnFadeOut} : prev, - ), - ); - } - - if (columnAnimation?.phase === 'fade-out') { - const fadeOutMs = parseCssDuration( - window.getComputedStyle(tableRef.current!).getPropertyValue('--animated-column-fade-out-duration'), - ); - return setTimeoutWithCleanup( - () => setColumnAnimation(prev => (prev === columnAnimation ? null : prev)), - fadeOutMs, - ); - } - - return undefined; - }, [columnAnimation, tableRef]); - return {columnAnimation, expectColumnReorder}; -} diff --git a/src/table/internal/reorder-animation-context.ts b/src/table/internal/reorder-animation-context.ts new file mode 100644 index 00000000000..f691bef6915 --- /dev/null +++ b/src/table/internal/reorder-animation-context.ts @@ -0,0 +1,121 @@ +import {createContext, type RefObject, useEffect, useRef, useState} from 'react'; + +import {parseCssDuration} from '../../global/parse-css-duration'; +import {requestAnimationFrameWithCleanup, setTimeoutWithCleanup} from '../../global/schedule-with-cleanup'; + +import type {Column} from '../table-props'; +import type {ReorderAnimation} from '../reorder-animation'; + +import styles from '../table.css'; + +/** How long to wait for the data to change after {@link expectReorder} is called. */ +const pendingReorderTimeoutMs = 1000; + +export interface ReorderSpec { + direction: 'columns' | 'items'; + fromIndex: number; + insertionIndex: number; +} + +interface ReorderAnimationContextValue { + reorderAnimation: ReorderAnimation | null; + expectReorder: (reorderSpec: ReorderSpec) => void; +} + +export const ReorderAnimationContext = createContext({ + reorderAnimation: null, + expectReorder: () => {}, +}); + +export function useReorderAnimationContextValue({ + noColumnReorderAnimation, + noItemReorderAnimation, + tableRef, + data, + columns, +}: { + noColumnReorderAnimation: boolean | undefined; + noItemReorderAnimation: boolean | undefined; + tableRef: RefObject; + data: readonly T[]; + columns: readonly Column[]; +}): ReorderAnimationContextValue { + const [reorderAnimation, setReorderAnimation] = useState(null); + + interface PendingReorder extends ReorderSpec { + timerId: number; + data: readonly T[]; + columns: readonly Column[]; + } + + const pendingReorderRef = useRef(null); + + function expectReorder(reorderSpec: ReorderSpec) { + const isColumn = reorderSpec.direction === 'columns'; + if ((isColumn && noColumnReorderAnimation) || (!isColumn && noItemReorderAnimation)) return; + + if (pendingReorderRef.current) { + window.clearTimeout(pendingReorderRef.current.timerId); + } + + const timerId = window.setTimeout(() => { + pendingReorderRef.current = null; + }, pendingReorderTimeoutMs); + pendingReorderRef.current = {...reorderSpec, timerId, columns, data}; + } + + useEffect(() => { + return () => { + if (pendingReorderRef.current) { + window.clearTimeout(pendingReorderRef.current.timerId); + pendingReorderRef.current = null; + } + }; + }, []); + + useEffect(() => { + const table = tableRef.current; + const pendingReorder = pendingReorderRef.current; + if (!table || !pendingReorder) return; + + const {direction, data: pendingData, columns: pendingColumns} = pendingReorder; + const isColumn = direction === 'columns'; + if ((isColumn && columns === pendingColumns) || (!isColumn && data === pendingData)) return; + + pendingReorderRef.current = null; + + const {fromIndex, insertionIndex} = pendingReorder; + + // Moving forward shifts the array by one, hence -1 + const index = fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex; + return requestAnimationFrameWithCleanup(() => + setReorderAnimation(prev => + prev == null ? {direction, index, phase: 'initial', className: styles.reorderAnimationInitial} : prev, + ), + ); + }, [data, columns, tableRef]); + + useEffect(() => { + if (reorderAnimation?.phase === 'initial') { + return requestAnimationFrameWithCleanup(() => + setReorderAnimation(prev => + prev === reorderAnimation ? {...prev, phase: 'fade-out', className: styles.reorderAnimationFadeOut} : prev, + ), + ); + } + + if (reorderAnimation?.phase === 'fade-out') { + const fadeOutMs = parseCssDuration( + window.getComputedStyle(tableRef.current!).getPropertyValue('--reorder-animation-fade-out-duration'), + ); + return setTimeoutWithCleanup( + () => setReorderAnimation(prev => (prev === reorderAnimation ? null : prev)), + fadeOutMs, + ); + } + + return undefined; + }, [reorderAnimation, tableRef]); + + return {reorderAnimation, expectReorder}; +} diff --git a/src/table/internal/reorder-handle.tsx b/src/table/internal/reorder-handle.tsx new file mode 100644 index 00000000000..85624233f8b --- /dev/null +++ b/src/table/internal/reorder-handle.tsx @@ -0,0 +1,468 @@ +/* eslint-disable max-lines */ +import { + type ComponentPropsWithRef, + type Context, + type PointerEvent, + use, + useEffect, + useEffectEvent, + useRef, +} from 'react'; +import classNames from 'classnames'; +import dragIcon from '@jetbrains/icons/drag-12px'; + +import {type TableProps} from '../table-props'; +import {TablePropsContext} from '../table-const'; +import Icon from '../../icon'; +import {useComposedRef} from '../../global/compose-refs'; +import {parseCssDuration} from '../../global/parse-css-duration'; +import {ReorderAnimationContext} from './reorder-animation-context'; +import {type InsertionPoint, ReorderLayoutContext} from './reorder-layout-context'; + +import type {DragState} from '../table-primitives'; + +import styles from '../table.css'; + +/** + * All coordinates are client coordinates (pixels relative to the viewport). + */ +interface ActiveDrag { + state: 'is-dragging' | 'ended-with-no-change'; + startX: number; + startY: number; + initialItemStart: number; + initialItemEnd: number; + /** + * Top (columns) or left (items) + */ + indicatorStart: number; + /** + * Height (columns) or width (items) + */ + indicatorSize: string; + cleanup: () => void; +} + +const columnDragFrameAdjustmentPx = -1; +const itemDragFrameAdjustmentPx = -2; + +const scrollAreaPx = 100; +const scrollStepPx = 100; +const scrollIntervalMs = 200; + +export function ReorderHandle({ + direction, + index, + noDragFrame, + noHandleTranslate, + onUserDrag, + + ref: userRef, + className, + onKeyDown, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture, + ...restProps +}: { + direction: 'columns' | 'items'; + index: number; + noDragFrame?: boolean; + noHandleTranslate?: boolean; + onUserDrag?: (state: DragState) => void; +} & ComponentPropsWithRef<'button'>) { + const localRef = useRef(null); + const composedRef = useComposedRef(localRef, userRef); + + const isColumn = direction === 'columns'; + + const tableProps = use(TablePropsContext as Context | null>); + const data = tableProps?.data; + const columns = tableProps?.columns; + const canReorderItem = tableProps?.canReorderItem; + const onItemReorder = tableProps?.onItemReorder; + const onColumnReorder = tableProps?.onColumnReorder; + + function canReorder(insertionIndex: number) { + const columnBeingReordered = columns?.[index]; + if (isColumn && columnBeingReordered) { + const canReorderColumn = columnBeingReordered.canReorder; + if (canReorderColumn === true) return true; + if (typeof canReorderColumn === 'function') + return canReorderColumn(columnBeingReordered, index, insertionIndex, columns); + return false; + } + + if (!isColumn && data) { + return canReorderItem ? canReorderItem(data[index], index, insertionIndex, data) : true; + } + + return false; + } + + const {expectReorder} = use(ReorderAnimationContext); + + function onReorder(insertionIndex: number) { + if (isColumn && columns) { + expectReorder({direction, fromIndex: index, insertionIndex}); + onColumnReorder?.(columns[index], index, insertionIndex, columns); + } else if (!isColumn && data) { + expectReorder({direction, fromIndex: index, insertionIndex}); + onItemReorder?.(data[index], index, insertionIndex, data); + } + } + + const activeDragRef = useRef(null); + + function getDragFrame() { + return document.body.querySelector(`.${styles.dragFrame}`) as HTMLDivElement | null; + } + + function getInsertionIndicator() { + return document.body.querySelector(`.${styles.insertionIndicator}`) as HTMLDivElement | null; + } + + const {getItemBounds, getClosestInsertionPoint} = use(ReorderLayoutContext); + + function renderDragFrame(clientX: number, clientY: number) { + const drag = activeDragRef.current; + if (noDragFrame || !drag) return; + + const {startX, startY, initialItemStart, initialItemEnd, indicatorStart, indicatorSize} = drag; + + let dragFrame = getDragFrame(); + if (!dragFrame) { + dragFrame = document.createElement('div'); + dragFrame.className = styles.dragFrame; + + const frameStart = `calc(max(0px, ${indicatorStart - 2}px))`; + const frameAcrossSize = `${initialItemEnd - initialItemStart}px`; + const frameAlongSize = indicatorSize; + dragFrame.style[isColumn ? 'top' : 'left'] = frameStart; + dragFrame.style[isColumn ? 'width' : 'height'] = frameAcrossSize; + dragFrame.style[isColumn ? 'height' : 'width'] = frameAlongSize; + + document.body.appendChild(dragFrame); + } + + if (isColumn) { + dragFrame.style.left = `${initialItemStart + clientX - startX + columnDragFrameAdjustmentPx}px`; + } else { + dragFrame.style.top = `${initialItemStart + clientY - startY + itemDragFrameAdjustmentPx}px`; + } + } + + function translateButton(clientX: number, clientY: number) { + const btn = localRef.current; + const drag = activeDragRef.current; + if (noHandleTranslate || !btn || !drag) return; + const {startX, startY, initialItemStart} = drag; + const offsetByPointerMove = isColumn ? clientX - startX : clientY - startY; + + const {start: itemStart} = getItemBounds(index) ?? {start: 0, end: 0}; + const offsetByItemMove = itemStart - initialItemStart; + + const offset = offsetByPointerMove - offsetByItemMove; + btn.style.transform = isColumn ? `translateX(${offset}px)` : `translateY(${offset}px)`; + } + + function getClosestInsertionPointLocal(clientX: number, clientY: number) { + const clientOffset = isColumn ? clientX : clientY; + return getClosestInsertionPoint(clientOffset, canReorder); + } + + function renderInsertionIndicator({itemIndex, after}: InsertionPoint) { + const drag = activeDragRef.current; + if (!drag) return; + + const {indicatorStart, indicatorSize} = drag; + + const itemBounds = getItemBounds(itemIndex); + if (!itemBounds) return; + + const {start: itemStart, end: itemEnd} = itemBounds; + + let indicator = getInsertionIndicator(); + if (!indicator) { + indicator = document.createElement('div'); + indicator.className = styles.insertionIndicator; + + indicator.style[isColumn ? 'top' : 'left'] = `${indicatorStart}px`; + indicator.style[isColumn ? 'height' : 'width'] = indicatorSize; + indicator.style[isColumn ? 'width' : 'height'] = '2px'; + + document.body.appendChild(indicator); + } + + const itemOffset = `${(after ? itemEnd : itemStart) - 1}px`; + indicator.style[isColumn ? 'left' : 'top'] = itemOffset; + } + + function cleanupDrag() { + if (activeDragRef.current) { + activeDragRef.current.cleanup(); + activeDragRef.current = null; + } + + const btn = localRef.current; + if (btn) { + btn.style.removeProperty('transform'); + btn.style.removeProperty('transition'); + } + + getDragFrame()?.remove(); + getInsertionIndicator()?.remove(); + + onUserDrag?.(undefined); + } + + function animateNoChangeThenCleanup() { + const drag = activeDragRef.current; + if (drag?.state !== 'is-dragging') return; + + drag.state = 'ended-with-no-change'; + drag.cleanup(); + drag.cleanup = () => {}; + + const dragFrame = getDragFrame(); + if (dragFrame) { + const {initialItemStart} = drag; + if (isColumn) { + dragFrame.style.left = `${initialItemStart + columnDragFrameAdjustmentPx}px`; + } else { + dragFrame.style.top = `${initialItemStart + itemDragFrameAdjustmentPx}px`; + } + dragFrame.style.transition = 'left var(--ring-ease), top var(--ring-ease), opacity var(--ring-ease)'; + dragFrame.style.opacity = '0'; + } + + const indicator = getInsertionIndicator(); + if (indicator) { + indicator.style.opacity = '0'; + indicator.style.transition = 'opacity var(--ring-ease)'; + } + + const btn = localRef.current; + if (btn) { + btn.style.transform = isColumn ? 'translateX(0)' : 'translateY(0)'; + btn.style.transition = 'transform var(--ring-ease)'; + } + + onUserDrag?.('cancelled'); + + const ringEaseMs = parseCssDuration( + window.getComputedStyle(document.documentElement).getPropertyValue('--ring-ease'), + ); + setTimeout(cleanupDrag, ringEaseMs); + } + + function handlePointerDown(e: PointerEvent) { + onPointerDown?.(e); + if (e.defaultPrevented) return; + + const {clientX, clientY, pointerId, currentTarget} = e; + + const {start: initialItemStart, end: initialItemEnd} = getItemBounds(index) ?? {start: 0, end: 0}; + + let indicatorStart: number; + let indicatorSize: string; + + if (isColumn) { + const thead = currentTarget.closest('thead'); + const table = thead?.closest('table'); + if (!thead || !table) return; + + const {top: headerTop} = thead.getBoundingClientRect(); + indicatorStart = headerTop; + + const {bottom: tableBottom} = table.getBoundingClientRect(); + const visibleTableHeight = tableBottom - headerTop; + const viewportBottomRelativeToHeaderTop = window.innerHeight - headerTop; + indicatorSize = `min(${visibleTableHeight}px, calc(${viewportBottomRelativeToHeaderTop}px - .5rem))`; + } else { + const tr = currentTarget.closest('tr'); + const tbody = tr?.closest('tbody'); + if (!tr || !tbody) return; + + const {left: itemLeft, right: itemRight} = tr.getBoundingClientRect(); + indicatorStart = itemLeft; + + const visibleItemWidth = itemRight - itemLeft; + const viewportRightRelativeToTableLeft = window.innerWidth - itemLeft; + indicatorSize = `min(${visibleItemWidth}px, calc(${viewportRightRelativeToTableLeft}px - .5rem))`; + } + + function keydownListener(keyEvent: KeyboardEvent) { + if (keyEvent.key === 'Escape') { + animateNoChangeThenCleanup(); + keyEvent.stopPropagation(); + keyEvent.preventDefault(); + } + } + + currentTarget.setPointerCapture(pointerId); + document.addEventListener('keydown', keydownListener); + currentTarget.style.cursor = 'grabbing'; + + activeDragRef.current = { + state: 'is-dragging', + startX: clientX, + startY: clientY, + initialItemStart, + initialItemEnd, + indicatorStart, + indicatorSize, + cleanup: () => { + currentTarget.releasePointerCapture(pointerId); + document.removeEventListener('keydown', keydownListener); + currentTarget.style.removeProperty('cursor'); + }, + }; + + renderDragFrame(clientX, clientY); + onUserDrag?.('pointerdown'); + + e.preventDefault(); + } + + const scrollerRef = tableProps?.scrollerRef; + + const lastScrolledRef = useRef(0); + + function scrollThrottled(scrollDirection: 'up' | 'down') { + const now = performance.now(); + if (now > lastScrolledRef.current + scrollIntervalMs) { + lastScrolledRef.current = now; + const top = scrollDirection === 'up' ? -scrollStepPx : scrollStepPx; + const scroller = scrollerRef?.current ?? window; + scroller?.scrollBy({top, behavior: 'smooth'}); + } + } + + function handlePointerMove(e: PointerEvent) { + onPointerMove?.(e); + if (e.defaultPrevented) return; + + const drag = activeDragRef.current; + if (drag?.state !== 'is-dragging') return; + + const {clientX, clientY} = e; + renderDragFrame(clientX, clientY); + translateButton(clientX, clientY); + + const insertionPoint = getClosestInsertionPointLocal(clientX, clientY); + if (insertionPoint) renderInsertionIndicator(insertionPoint); + + onUserDrag?.(isColumn ? clientX - drag.startX : clientY - drag.startY); + + if (!isColumn) { + const scrollerRect = scrollerRef?.current?.getBoundingClientRect(); + const scrollerTop = scrollerRect?.top ?? 0; + const scrollerBottom = scrollerRect ? Math.min(scrollerRect.bottom, window.innerHeight) : window.innerHeight; + if (clientY < scrollerTop + scrollAreaPx) { + scrollThrottled('up'); + } else if (clientY > scrollerBottom - scrollAreaPx) { + scrollThrottled('down'); + } + } + } + + function handlePointerUp(e: PointerEvent) { + onPointerUp?.(e); + if (e.defaultPrevented) return; + + if (activeDragRef.current?.state !== 'is-dragging') return; + + const {clientX, clientY} = e; + const insertionPoint = getClosestInsertionPointLocal(clientX, clientY); + const insertionIndex = insertionPoint && insertionPoint.itemIndex + (insertionPoint.after ? 1 : 0); + + if (insertionIndex == null || insertionIndex === index || insertionIndex === index + 1) { + animateNoChangeThenCleanup(); + return; + } + + cleanupDrag(); + onReorder(insertionIndex); + } + + function handleKeyDown(e: React.KeyboardEvent) { + onKeyDown?.(e); + if (e.defaultPrevented) return; + + const left = isColumn && e.key === 'ArrowLeft'; + const right = isColumn && e.key === 'ArrowRight'; + const up = !isColumn && e.key === 'ArrowUp'; + const down = !isColumn && e.key === 'ArrowDown'; + if ((!left && !right && !up && !down) || !columns || !data) return; + + const backward = left || up; + const initialInsertionIndex = backward ? index - 1 : index + 2; + const maxInsertionIndex = isColumn ? columns.length : data.length; + const step = backward ? -1 : 1; + + // eslint-disable-next-line yoda + for (let i = initialInsertionIndex; 0 <= i && i <= maxInsertionIndex; i += step) { + if (canReorder(i)) { + onReorder(i); + e.preventDefault(); + return; + } + } + } + + function handlePointerCancel(e: PointerEvent) { + onPointerCancel?.(e); + if (!e.defaultPrevented) animateNoChangeThenCleanup(); + } + + function handleLostPointerCapture(e: PointerEvent) { + onLostPointerCapture?.(e); + if (!e.defaultPrevented) animateNoChangeThenCleanup(); + } + + const cleanupComponent = useEffectEvent(() => { + if (activeDragRef.current) { + cleanupDrag(); + } + }); + + useEffect(() => { + return cleanupComponent; + }, []); + + const hint = isColumn + ? `Reorder column ${columns?.[index]?.name ?? String(columns?.[index]?.key)}.` + : `Reorder item ${index + 1}.`; + const description = isColumn + ? 'Use Left and Right arrow keys to move the column.' + : 'Use Up and Down arrow keys to move the item.'; + const shortcuts = isColumn ? 'ArrowLeft ArrowRight' : 'ArrowUp ArrowDown'; + + return ( + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ); +} diff --git a/src/table/internal/reorder-layout-context.ts b/src/table/internal/reorder-layout-context.ts new file mode 100644 index 00000000000..b9410bb3ca1 --- /dev/null +++ b/src/table/internal/reorder-layout-context.ts @@ -0,0 +1,93 @@ +import {createContext, useRef} from 'react'; + +interface ItemBounds { + start: number; + end: number; +} + +export interface InsertionPoint { + itemIndex: number; + after: boolean; +} + +interface ReorderLayoutContextValue { + registerReorderItem(index: number, getBounds: () => ItemBounds): () => void; + getItemBounds(index: number): ItemBounds | undefined; + getClosestInsertionPoint( + clientOffset: number, + canReorder: (insertionIndex: number) => boolean, + ): InsertionPoint | undefined; +} + +export const ReorderLayoutContext = createContext({ + registerReorderItem: () => () => {}, + getItemBounds: () => undefined, + getClosestInsertionPoint: () => undefined, +}); + +export function useReorderLayoutContextValue(): ReorderLayoutContextValue { + const getBoundsByItemIndex = useRef<(() => ItemBounds)[]>([]); + + function registerReorderItem(index: number, getBounds: () => ItemBounds) { + getBoundsByItemIndex.current[index] = getBounds; + return () => { + delete getBoundsByItemIndex.current[index]; + }; + } + + function getItemBounds(index: number) { + const getBounds = getBoundsByItemIndex.current[index]; + return getBounds?.(); + } + + function getClosestInsertionPoint(clientOffset: number, canReorder: (insertionIndex: number) => boolean) { + const candidates = getBoundsByItemIndex.current + .map((getBounds, itemIndex) => ({ + itemIndex, + getBounds, + beforeAllowed: canReorder(itemIndex), + afterAllowed: canReorder(itemIndex + 1), + })) + .filter(({beforeAllowed, afterAllowed}) => beforeAllowed || afterAllowed); + + if (!candidates.length) return undefined; + + // Lazily computed closest insertion side and distance for each candidate + const closest: ({distance: number; after: boolean} | undefined)[] = []; + + function computeClosest(i: number) { + if (!closest[i]) { + const {getBounds, beforeAllowed, afterAllowed} = candidates[i]; + const {start, end} = getBounds(); + const beforeDist = Math.abs(clientOffset - start); + const afterDist = Math.abs(clientOffset - end); + if (!afterAllowed) { + closest[i] = {distance: beforeDist, after: false}; + } else if (!beforeAllowed) { + closest[i] = {distance: afterDist, after: true}; + } else { + const after = afterDist < beforeDist; + closest[i] = {distance: after ? afterDist : beforeDist, after}; + } + } + return closest[i]!; + } + + let l = 0; + let r = candidates.length - 1; + while (l < r) { + const m = Math.floor((l + r) / 2); + const {distance} = computeClosest(m); + if (l <= m - 1 && computeClosest(m - 1).distance < distance) { + r = m - 1; + } else if (m + 1 <= r && computeClosest(m + 1).distance < distance) { + l = m + 1; + } else { + return {itemIndex: candidates[m].itemIndex, after: computeClosest(m).after}; + } + } + return {itemIndex: candidates[l].itemIndex, after: computeClosest(l).after}; + } + + return {registerReorderItem, getItemBounds, getClosestInsertionPoint}; +} diff --git a/src/table/internal/table-header.tsx b/src/table/internal/table-header.tsx index 863671e4caf..31e486108c9 100644 --- a/src/table/internal/table-header.tsx +++ b/src/table/internal/table-header.tsx @@ -1,24 +1,24 @@ -/* eslint-disable no-nested-ternary, max-lines */ -import {type ComponentPropsWithRef, type Context, use, useCallback, useRef, useState, type PointerEvent} from 'react'; +/* eslint-disable no-nested-ternary */ +import {type ComponentPropsWithRef, type Context, use, useCallback, useRef, useState} from 'react'; import classNames from 'classnames'; import arrowDownIcon from '@jetbrains/icons/arrow-12px-down'; import arrowUpIcon from '@jetbrains/icons/arrow-12px-up'; -import dragIcon from '@jetbrains/icons/drag-12px'; import settingsIcon from '@jetbrains/icons/settings-12px'; import trashIcon from '@jetbrains/icons/trash-12px'; import unsortedIcon from '@jetbrains/icons/unsorted-12px'; import {type TableProps} from '../table-props'; -import {ColumnAnimationContext, TablePropsContext} from '../table-const'; -import {type ExpectColumnReorder} from './column-animation'; +import {TablePropsContext} from '../table-const'; import Icon from '../../icon'; -import {useComposedRef} from '../../global/compose-refs'; import {isWithinInteractiveElement} from '../../global/is-within-interactive-element'; -import {parseCssDuration} from '../../global/parse-css-duration'; +import {ReorderHandle} from './reorder-handle'; +import {ReorderAnimationContext} from './reorder-animation-context'; +import {ReorderLayoutContext, useReorderLayoutContextValue} from './reorder-layout-context'; +import {useReorderItemLayout} from '../reorder-item-layout'; import styles from '../table.css'; -export function TableHeader({expectColumnReorder}: {expectColumnReorder: ExpectColumnReorder}) { +export function TableHeader() { const {columns, noHeader, stickyHeader, columnEditing, onColumnEditingRequest, theadClassName, theadTrClassName} = use(TablePropsContext as Context>); @@ -53,6 +53,8 @@ export function TableHeader({expectColumnReorder}: {expectColumnReorder: Expe toggleColumnEditing('edit-button'); }, [toggleColumnEditing]); + const reorderContextValue = useReorderLayoutContextValue(); + if (noHeader) return null; return ( @@ -66,15 +68,16 @@ export function TableHeader({expectColumnReorder}: {expectColumnReorder: Expe onClick={handleTheadClick} > - {columns.map((column, columnIndex) => ( - - ))} + + {columns.map((column, columnIndex) => ( + + ))} + ); @@ -84,24 +87,35 @@ function TableHeaderCell({ columnIndex, columnEditing, handleEditColumnsButtonClick, - expectColumnReorder, }: { columnIndex: number; columnEditing: boolean; handleEditColumnsButtonClick: () => void; - expectColumnReorder: ExpectColumnReorder; }) { + const ref = useRef(null); + const {columns, columnEditButton} = use(TablePropsContext as Context>); const {key, name, renderHeader, sortOrder, deletable, canReorder, thClassName} = columns[columnIndex]; - const animatedColumn = use(ColumnAnimationContext); + const {reorderAnimation} = use(ReorderAnimationContext); const children = renderHeader ? renderHeader() : (name ?? String(key)); + useReorderItemLayout({ + index: columnIndex, + getBounds: () => { + const r = ref.current?.getBoundingClientRect(); + return {start: r?.left ?? 0, end: r?.right ?? 0}; + }, + }); + return ( ({ >
- {canReorder && } + {canReorder && } {sortOrder ? ( {children} @@ -167,7 +181,7 @@ function SortButton({ } return ( - ); @@ -186,7 +200,8 @@ function DeleteColumnButton({ (e: React.MouseEvent) => { onClick?.(e); if (!e.defaultPrevented) { - tableProps!.onColumnDelete?.(columnIndex, tableProps!.columns); + const columns = tableProps!.columns; + tableProps!.onColumnDelete?.(columns[columnIndex], columnIndex, columns); } }, [columnIndex, onClick, tableProps], @@ -201,7 +216,7 @@ function DeleteColumnButton({ return ( - ); +function ColumnReorderHandle({columnIndex, ...restProps}: {columnIndex: number} & ComponentPropsWithRef<'button'>) { + return ; } /** diff --git a/src/table/internal/virtual-items.tsx b/src/table/internal/virtualization.tsx similarity index 91% rename from src/table/internal/virtual-items.tsx rename to src/table/internal/virtualization.tsx index 563d457c846..0e2859bd83e 100644 --- a/src/table/internal/virtual-items.tsx +++ b/src/table/internal/virtualization.tsx @@ -1,6 +1,9 @@ import {createContext, type RefObject, useCallback, useEffect, useRef, useState} from 'react'; -import {useIntersectionObserverHandle} from '../../global/intersection-observer-context'; +import { + type IntersectionObserverHandle, + useIntersectionObserverHandle, +} from '../../global/intersection-observer-context'; import {setTimeoutWithCleanup} from '../../global/schedule-with-cleanup'; import styles from '../table.css'; @@ -27,7 +30,15 @@ interface Spacer { type CollapseItemIntoSpacerCallback = (index: number, height: number) => void; -export const CollapseItemIntoSpacerContext = createContext(() => {}); +interface VirtualizationContextValue { + intersectionObserverHandle: IntersectionObserverHandle; + collapseItemIntoSpacer: CollapseItemIntoSpacerCallback; +} + +export const VirtualizationContext = createContext({ + intersectionObserverHandle: {observe: () => () => {}}, + collapseItemIntoSpacer: () => {}, +}); /** * RAF is somewhat too frequent. Most updates happen on virtualization boundaries @@ -235,20 +246,16 @@ export function useVirtualItems({ !scrollerRef ? retentionMarginPx : undefined, ); - const collapseItemIntoSpacer = useCallback( - (index: number, height: number) => { - if (!enabled) return; + function collapseItemIntoSpacer(index: number, height: number) { + if (!enabled) return; - itemsMaterialization.current[index] = height; - throttle(recomputeVirtualItems); - }, - [enabled, throttle, recomputeVirtualItems], - ); + itemsMaterialization.current[index] = height; + throttle(recomputeVirtualItems); + } return { virtualItems, - intersectionObserverHandle, - collapseItemIntoSpacer, + virtualizationContextValue: {intersectionObserverHandle, collapseItemIntoSpacer}, }; } diff --git a/src/table/item-virtualization.ts b/src/table/item-virtualization.ts index ce44fccd173..386c30660b2 100644 --- a/src/table/item-virtualization.ts +++ b/src/table/item-virtualization.ts @@ -1,7 +1,6 @@ import {type RefObject, use, useEffect} from 'react'; -import {IntersectionObserverContext} from '../global/intersection-observer-context'; -import {CollapseItemIntoSpacerContext} from './internal/virtual-items'; +import {VirtualizationContext} from './internal/virtualization'; /** * Use in an item renderer to control item virtualization. @@ -22,14 +21,16 @@ export function useItemVirtualization({ * When multiple elements are provided, the virtualization callback receives * the intersection state of all of them. * - * If you pass multiple refs, memoize the array (for example with `useMemo()`) - * to avoid restarting observation on every render. + * If you pass multiple refs, pass a stable array reference to avoid restarting + * observation on every render — for example, memoize it with `useMemo()`, + * unless you use the React Compiler. */ refs: RefObject | RefObject[]; /** * Invoked when the `isIntersecting` state of the observed elements changes. - * Consider wrapping a callback to `useCallback()` to avoid restarting observation on every render. + * Pass a stable function reference to avoid restarting observation on every render — + * for example, wrap it with `useCallback()`, unless you use the React Compiler. * * @param intersectionStates - Current intersection state of every observed element. * Entries are initially `undefined` until the corresponding element @@ -47,8 +48,7 @@ export function useItemVirtualization({ elements: (Element | null)[], ) => number | undefined; }) { - const handle = use(IntersectionObserverContext); - const collapseItemIntoSpacer = use(CollapseItemIntoSpacerContext); + const {intersectionObserverHandle, collapseItemIntoSpacer} = use(VirtualizationContext); useEffect(() => { const intersectionStates: (boolean | undefined)[] = Array.isArray(refs) ? refs.map(() => undefined) : [undefined]; @@ -58,7 +58,7 @@ export function useItemVirtualization({ elements.forEach((element, elementIndex) => { if (!element) return; - const cleanup = handle.observe(element, isIntersecting => { + const cleanup = intersectionObserverHandle.observe(element, isIntersecting => { intersectionStates[elementIndex] = isIntersecting; const height = onIntersectionChange(intersectionStates, elementIndex, elements); @@ -70,5 +70,5 @@ export function useItemVirtualization({ }); return () => cleanups.forEach(cleanup => cleanup()); - }, [collapseItemIntoSpacer, handle, index, onIntersectionChange, refs]); + }, [collapseItemIntoSpacer, intersectionObserverHandle, index, onIntersectionChange, refs]); } diff --git a/src/table/reorder-animation.ts b/src/table/reorder-animation.ts new file mode 100644 index 00000000000..b138f289aae --- /dev/null +++ b/src/table/reorder-animation.ts @@ -0,0 +1,47 @@ +import {use} from 'react'; + +import {ReorderAnimationContext} from './internal/reorder-animation-context'; + +/** + * Information about a column or item currently being animated after reorder. + * + * Available through {@link useReorderAnimation} to allow custom cell + * renderers to animate reordered columns and items consistently with the default + * table renderer. + */ +export interface ReorderAnimation { + /** + * What is animated: columns or items (rows). + */ + direction: 'columns' | 'items'; + + /** + * Index of the column or item being animated. + */ + index: number; + + /** + * Current animation phase. + */ + phase: 'initial' | 'fade-out'; + + /** + * CSS class to apply to the animated row, cell, or another element used to + * render the animation on this phase. + * + * The class sets `background-color` and `transition`. If your custom styles + * also define `transition` on the same element, they may conflict with the + * animation. + */ + className: string; +} + +/** + * Provides information about the currently animated column or item after reorder. + * + * Use in a custom cell renderer to animate reordered columns and items + * consistently with the default table renderer. + */ +export function useReorderAnimation(): ReorderAnimation | null { + return use(ReorderAnimationContext).reorderAnimation; +} diff --git a/src/table/reorder-item-layout.ts b/src/table/reorder-item-layout.ts new file mode 100644 index 00000000000..952ad786045 --- /dev/null +++ b/src/table/reorder-item-layout.ts @@ -0,0 +1,44 @@ +import {use, useEffect} from 'react'; + +import {ReorderLayoutContext} from './internal/reorder-layout-context'; + +/** + * Registers the physical boundaries of a custom-rendered item with the reorder + * system, so that the insertion indicator and insertion point calculation are + * correct for items that span multiple rows or have non-standard sizing. + * + * `DefaultItemRenderer` calls this automatically. Use this hook in a custom item + * renderer when the default boundary measurement is insufficient — for example, + * when the item spans multiple rows, or when you don't use + * the `DefaultItemRenderer` at all. + * + * If you include `DefaultItemRenderer` inside your custom renderer, set its + * `noReorderLayout` prop to `true` to prevent double registration. + */ +export function useReorderItemLayout({ + disabled, + index, + getBounds, +}: { + /** + * When `true`, the item is not registered. Use to disable without violating + * the rules of hooks. + */ + disabled?: boolean; + /** + * Index of the item in the `data` array. + */ + index: number; + /** + * Returns the item's top and bottom client coordinates (in pixels, relative + * to the viewport). + */ + getBounds: () => {start: number; end: number}; +}) { + const {registerReorderItem} = use(ReorderLayoutContext); + useEffect(() => { + if (disabled) return; + + return registerReorderItem(index, getBounds); + }, [index, getBounds, registerReorderItem, disabled]); +} diff --git a/src/table/table-const.ts b/src/table/table-const.ts index 6bf05191b48..194530c5688 100644 --- a/src/table/table-const.ts +++ b/src/table/table-const.ts @@ -8,42 +8,6 @@ import type {TableProps} from './table-props'; */ export const TablePropsContext = createContext | null>(null); -/** - * Information about a column reorder animation. - * - * Available through {@link ColumnAnimationContext} to allow custom cell - * renderers to animate reordered columns consistently with the default - * table renderer. - */ -export interface ColumnAnimation { - /** - * Index of the column being animated. - */ - columnIndex: number; - - /** - * Current animation phase. - */ - phase: 'initial' | 'fade-out'; - - /** - * CSS class to apply to the animated cell or another element used to - * render the animation. - * - * The class only defines the `background-color` and `transition` - * properties. - */ - cellClassName: string; -} - -/** - * Provides information about the currently animated column. - * - * Use in a custom cell renderer to animate reordered columns - * consistently with the default table renderer. - */ -export const ColumnAnimationContext = createContext(null); - /** * When a row only contains unformatted single-line text, it will be exactly of this height. */ diff --git a/src/table/table-primitives.tsx b/src/table/table-primitives.tsx index 81ff0c928ae..e039d552b22 100644 --- a/src/table/table-primitives.tsx +++ b/src/table/table-primitives.tsx @@ -1,5 +1,7 @@ import classNames from 'classnames'; +import {ReorderHandle} from './internal/reorder-handle'; + import type {ComponentPropsWithRef} from 'react'; import styles from './table.css'; @@ -36,3 +38,51 @@ export function TableCell(props: ComponentPropsWithRef<'td'>) { const classes = classNames(styles.cell, className); return ; } + +/** + * - `'pointerdown'` means the user has pressed the mouse button or touched the handle. + * - `number` means the distance in pixels the user has dragged the handle along + * the Y axis. + * - `'cancelled'` means the reorder was aborted — e.g. the user pressed Escape, + * released the pointer outside the browser, or dropped the item at its original + * position. Use this phase to play a cancellation animation in your custom renderer. + * This state lasts 600 ms (matching the built-in cancel animation), after which + * `undefined` is sent. + * - `undefined` means the drag interaction is fully over. Sent after a successful + * reorder as well as after the `'cancelled'` phase, so you can always do cleanup + * here without managing your own timer. + */ +export type DragState = 'pointerdown' | number | 'cancelled' | undefined; + +export interface ItemReorderHandleProps { + /** + * The index of the item in the table data array. + */ + index: number; + /** + * When `true`, the drag frame, indicating the currently dragged item, + * will not be rendered. + */ + noDragFrame?: boolean; + /** + * When `true`, the drag handle stays fixed in place instead of following + * the pointer during drag. + */ + noHandleTranslate?: boolean; + /** + * Callback that is called when the user drags the handle. + * Use it for custom drag indication. + * The `TableProps.onItemReorder` will be called after this callback. + * @param state The current drag state. + */ + onUserDrag?: (state: DragState) => void; +} + +/** + * A drag handle that allows the user to reorder the row. Place it anywhere + * inside a row renderer. + * Use {@link TableProps.canReorderItem} and {@link TableProps.onItemReorder}. + */ +export function ItemReorderHandle({index, ...restProps}: ItemReorderHandleProps & ComponentPropsWithRef<'button'>) { + return ; +} diff --git a/src/table/table-props.tsx b/src/table/table-props.tsx index 1b4d73b2d0a..1c580f46ab7 100644 --- a/src/table/table-props.tsx +++ b/src/table/table-props.tsx @@ -41,7 +41,7 @@ export interface TableProps { * Called when the user clicks on a column delete button in the header. * The client is expected to update the `columns` prop with the column removed. */ - onColumnDelete?: (columnIndex: number, columns: readonly Column[]) => void; + onColumnDelete?: (column: Column, columnIndex: number, columns: readonly Column[]) => void; /** * Called when the user reorders columns by dragging a column. @@ -51,15 +51,20 @@ export interface TableProps { * One possible implementation is: * * ```ts - * const [moved] = columns.splice(fromIndex, 1); - * columns.splice(fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex, 0, moved); + * columns.splice(fromIndex, 1); + * columns.splice(fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex, 0, columnBeingReordered); * ``` * * The callback is not called when the reorder operation would not change the * column order, i.e. when * `insertionIndex === fromIndex || insertionIndex === fromIndex + 1`. */ - onColumnReorder?: (fromIndex: number, insertionIndex: number, columns: readonly Column[]) => void; + onColumnReorder?: ( + columnBeingReordered: Column, + fromIndex: number, + insertionIndex: number, + columns: readonly Column[], + ) => void; /** * By default, when a column is reordered, the moved column is highlighted @@ -67,6 +72,33 @@ export interface TableProps { */ noColumnReorderAnimation?: boolean; + /** + * If defined, determines whether an item may be reordered to a specific insertion position. + * If not defined, any item may be reordered to any position. + */ + canReorderItem?: (itemBeingReordered: T, fromIndex: number, insertionIndex: number, items: readonly T[]) => boolean; + + /** + * Called when the user reorders items by dragging a handle. + * The `insertionIndex` parameter represents an insertion position in the original, + * unchanged `data` array before the item is removed. See {@link TableProps.onColumnReorder} + * for an example implementation. + * + * To make reorder possible, render `ItemReorderHandle` (from `table-primitives`) + * anywhere in a row. + * + * The callback is not called when the reorder operation would not change the + * item order, i.e. when + * `insertionIndex === fromIndex || insertionIndex === fromIndex + 1`. + */ + onItemReorder?: (itemBeingReordered: T, fromIndex: number, insertionIndex: number, items: readonly T[]) => void; + + /** + * By default, when an item is reordered, the moved item is highlighted + * with a temporary background color. Set `true` to disable this animation. + */ + noItemReorderAnimation?: boolean; + /** * Customizes how an item is rendered. * @@ -297,7 +329,14 @@ export interface Column { * Make sure {@link Column.name} or {@link Column.key} is meaningful, * as it will be included in the `aria-label` of the reorder button. */ - canReorder?: boolean | ((insertionIndex: number, columns: readonly Column[]) => boolean); + canReorder?: + | boolean + | (( + columnBeingReordered: Column, + fromIndex: number, + insertionIndex: number, + columns: readonly Column[], + ) => boolean); /** * The class name to apply to the `th` element inside `table > thead`. diff --git a/src/table/table.css b/src/table/table.css index e7e917a5517..68ccc45eadd 100644 --- a/src/table/table.css +++ b/src/table/table.css @@ -1,7 +1,7 @@ @import '../global/variables.css'; .table { - --animated-column-fade-out-duration: 600ms; + --reorder-animation-fade-out-duration: 600ms; width: 100%; @@ -40,7 +40,7 @@ &:hover { --header-cell-shadow-color: var(--ring-line-color); - &:not(.animatedColumnInitial, .animatedColumnFadeOut) { + &:not(.reorderAnimationInitial, .reorderAnimationFadeOut) { background-color: var(--ring-grey-container-light-color); } } @@ -67,7 +67,7 @@ } } -.headerButton { +.tableButton { margin: unset; padding: unset; @@ -134,6 +134,15 @@ touch-action: none; } +.itemReorderHandle { + cursor: grab; + + color: var(--ring-secondary-color); + + border-radius: var(--ring-border-radius); + touch-action: none; +} + .theadColumnEditing .columnReorderHandle, .headerCell:hover .columnReorderHandle, .columnReorderHandle:focus-visible { @@ -167,7 +176,7 @@ } } -.columnDragFrame { +.dragFrame { position: fixed; z-index: var(--ring-overlay-z-index); @@ -180,24 +189,22 @@ background-color: rgba(var(--ring-border-accent-components), 0.06); } -.columnInsertionIndicator { +.insertionIndicator { position: fixed; z-index: var(--ring-overlay-z-index); - width: 2px; - background-color: var(--ring-main-color); } -.animatedColumnInitial { +.reorderAnimationInitial { transition: none; background-color: rgba(var(--ring-border-accent-components), 0.12); } -.animatedColumnFadeOut { - transition: background-color var(--animated-column-fade-out-duration) ease-out; +.reorderAnimationFadeOut { + transition: background-color var(--reorder-animation-fade-out-duration) ease-out; background-color: transparent; } diff --git a/src/table/table.stories.css b/src/table/table.stories.css index fc684631083..1fe903a8c58 100644 --- a/src/table/table.stories.css +++ b/src/table/table.stories.css @@ -1,3 +1,5 @@ +@import '../global/variables.css'; + /* stylelint-disable selector-max-specificity */ .tdUrl { overflow: hidden; @@ -187,7 +189,7 @@ } } -.teamCityBuildDetails { +.noHeaderDetailsTable { width: calc(100% - 55px); margin-left: 55px; @@ -200,6 +202,11 @@ border: 0; &:first-child { + --w: 9em; + + width: var(--w); + max-width: var(--w); + font-weight: 600; } } @@ -224,3 +231,157 @@ pointer-events: none; } + +.dragStyle { + display: flex; + gap: 1em; +} + +.itemReorderTable { + --id-width: 46px; + --link-width: max(100px, 30vw); + + table-layout: fixed; + + & th:nth-child(1) { + width: var(--id-width); + } + + & th:last-child { + width: var(--link-width); + max-width: var(--link-width); + } + + & td:last-child > div { + overflow: hidden; + + width: 100%; + + white-space: nowrap; + text-overflow: ellipsis; + } +} + +.draggedItem { + position: fixed; + z-index: calc(var(--ring-overlay-z-index) + 1); + + transition: all 200ms ease-out, transform 0ms, opacity 0ms; + + border-radius: var(--ring-border-radius); + background-color: color-mix( + in srgb, + rgba(var(--ring-content-background-components), 0.5), + rgba(var(--ring-border-accent-components), 0.5) 20% + ); + backdrop-filter: blur(0.5px); + + & td { + transition: all 200ms ease-out; + + box-shadow: inset 0 1px 0 var(--ring-line-color); + + &:first-child { + border-top-left-radius: var(--ring-border-radius); + border-bottom-left-radius: var(--ring-border-radius); + box-shadow: inset 0 1px 0 var(--ring-line-color), inset 1px 0 0 var(--ring-line-color); + } + + &:last-child { + border-top-right-radius: var(--ring-border-radius); + border-bottom-right-radius: var(--ring-border-radius); + box-shadow: inset 0 1px 0 var(--ring-line-color), inset -1px 0 0 var(--ring-line-color); + } + } + + &.wide { + width: calc(100vw - 2 * var(--ring-unit)); + + & td:first-child { + width: var(--id-width); + } + + & td:nth-child(2), + & td:nth-child(3) { + width: calc(0.5 * (100vw - var(--id-width) - var(--link-width) - var(--ring-unit) * 10)); + max-width: calc(0.5 * (100vw - var(--id-width) - var(--link-width) - var(--ring-unit) * 10)); + } + + & td:last-child { + width: var(--link-width); + max-width: var(--link-width); + } + } + + &:not(.wide) { + --narrow-width: min(200px, calc(100vw - 2 * var(--ring-unit))); + + width: var(--narrow-width); + + & td:first-child { + width: var(--id-width); + } + + & td:nth-child(2) { + --w: calc(var(--narrow-width) - var(--id-width) - var(--ring-unit) * 6); + + overflow: hidden; + + width: var(--w); + max-width: var(--w); + } + + & td:nth-child(3) { + width: 0; + max-width: 0; + padding: 0; + + & > * { + display: none; + } + } + + & td:last-child { + width: 0; + max-width: 0; + + & > * { + display: none; + } + } + } +} + +.dragStub > td > * { + opacity: 0.2; +} + +.dragStubCancelled > td > * { + transition: opacity 300ms ease-out; + + opacity: 1; +} + +.customIssueScroller { + position: absolute; + top: calc(50% - 0.5 * 400px - 2 * var(--ring-unit)); + + overflow-y: scroll; + + box-sizing: border-box; + width: calc(100% - 2 * var(--ring-unit) - 2px); + height: 400px; + + border: 1px solid rgba(var(--ring-line-components), 0.5); +} + +.customIssueTable { + width: calc(100% - 2 * var(--ring-unit)); + margin: var(--ring-unit); + + table-layout: fixed; +} + +.customIssue td { + border-color: transparent; +} diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx index d2c0a340914..70adbd8c730 100644 --- a/src/table/table.stories.tsx +++ b/src/table/table.stories.tsx @@ -1,7 +1,7 @@ /* eslint-disable no-nested-ternary, react-hooks/rules-of-hooks */ -import { +import React, { type ComponentType, - use, + type RefObject, useCallback, useEffect, useEffectEvent, @@ -26,10 +26,14 @@ import Icon from '../icon/icon'; import Button from '../button/button'; import {focusWithTemporaryTabIndex} from '../global/focus-with-temporary-tabindex'; import {createRandom} from '../util-stories'; -import {ColumnAnimationContext} from './table-const'; import {isWithinInteractiveElement} from '../global/is-within-interactive-element'; import {useItemVirtualization} from './item-virtualization'; -import {TableCell, TableRow} from './table-primitives'; +import {type DragState, ItemReorderHandle, TableCell, TableRow} from './table-primitives'; +import Radio from '../radio/radio'; +import ControlLabel, {LabelType} from '../control-label/control-label'; +import {useReorderAnimation} from './reorder-animation'; +import {useReorderItemLayout} from './reorder-item-layout'; +import {defaultRowHeight} from './table-const'; import type {SortOrder, Column} from './table-props'; import type {Meta, StoryObj} from '@storybook/react'; @@ -185,8 +189,8 @@ export const WithAllColumnControls: TableStory<(typeof smallDataSlice)[number]> const [data, setData] = useState(args.data); const [columns, setColumns] = useState(args.columns); - function handleColumnDelete(columnIndex: number) { - setColumns(columns.filter((_, i) => i !== columnIndex)); + function handleColumnDelete(column: (typeof args)['columns'][number]) { + setColumns(columns.filter(c => c !== column)); } return ( @@ -198,7 +202,9 @@ export const WithAllColumnControls: TableStory<(typeof smallDataSlice)[number]> sortByColumn(args.data, columns, columnIndex, sortOrder, setData, setColumns) } onColumnDelete={handleColumnDelete} - onColumnReorder={(fromIndex, insertionIndex) => reorderColumns(columns, fromIndex, insertionIndex, setColumns)} + onColumnReorder={(_c, fromIndex, insertionIndex) => + reorderItems(columns, fromIndex, insertionIndex, setColumns) + } columnEditButton /> ); @@ -858,7 +864,9 @@ export const WithColumnReorder: TableStory<(typeof smallDataSlice)[number]> = { onSort={(columnIndex, sortOrder) => sortByColumn(args.data, columns, columnIndex, sortOrder, setData, setColumns) } - onColumnReorder={(fromIndex, insertionIndex) => reorderColumns(columns, fromIndex, insertionIndex, setColumns)} + onColumnReorder={(_c, fromIndex, insertionIndex) => + reorderItems(columns, fromIndex, insertionIndex, setColumns) + } columnEditButton /> ); @@ -871,16 +879,16 @@ export const WithColumnReorder: TableStory<(typeof smallDataSlice)[number]> = { tags: ['!autodocs'], }; -function reorderColumns( - columns: readonly Column[], +function reorderItems( + items: readonly T[], fromIndex: number, insertionIndex: number, - setColumns: (newColumns: readonly Column[]) => void, + setItems: (newItems: readonly T[]) => void, ) { - const [...newColumns] = columns; - const [moved] = newColumns.splice(fromIndex, 1); - newColumns.splice(fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex, 0, moved); - setColumns(newColumns); + const [...newItems] = items; + const [moved] = newItems.splice(fromIndex, 1); + newItems.splice(fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex, 0, moved); + setItems(newItems); } export const WithColumnReorderLongSticky: TableStory = { @@ -921,7 +929,9 @@ export const WithColumnReorderLongSticky: TableStory = { columns={columns} getKey={args.getKey} stickyHeader - onColumnReorder={(fromIndex, insertionIndex) => reorderColumns(columns, fromIndex, insertionIndex, setColumns)} + onColumnReorder={(_c, fromIndex, insertionIndex) => + reorderItems(columns, fromIndex, insertionIndex, setColumns) + } columnEditButton /> ); @@ -1035,7 +1045,7 @@ export const TeamCityBuildsSticky: TableStory = { }, { key: 'Id', - canReorder: i => i > 1, + canReorder: (_c, i) => i > 1, renderCell: ({id}) => (
@@ -1047,13 +1057,13 @@ export const TeamCityBuildsSticky: TableStory = { }, { key: 'Branch', - canReorder: i => i > 1, + canReorder: (_c, i) => i > 1, deletable: true, renderCell: ({branch}) =>
{branch}
, }, { key: 'Status', - canReorder: i => i > 1, + canReorder: (_c, i) => i > 1, deletable: true, renderCell: ({status}) => (
@@ -1075,13 +1085,13 @@ export const TeamCityBuildsSticky: TableStory = { }, { key: 'Agent', - canReorder: i => i > 1, + canReorder: (_c, i) => i > 1, deletable: true, renderCell: ({agent}) =>
{agent}
, }, { key: 'Started', - canReorder: i => i > 1, + canReorder: (_c, i) => i > 1, deletable: true, renderCell: ({started}) =>
{started ? format(started, dateShortFmt) : '—'}
, }, @@ -1105,10 +1115,10 @@ export const TeamCityBuildsSticky: TableStory = { renderItem={(item, index, items) => ( )} - onColumnReorder={(fromIndex, insertionIndex) => - reorderColumns(columns, fromIndex, insertionIndex, setColumns) + onColumnReorder={(_c, fromIndex, insertionIndex) => + reorderItems(columns, fromIndex, insertionIndex, setColumns) } - onColumnDelete={columnIndex => setColumns(columns.filter((_, i) => i !== columnIndex))} + onColumnDelete={column => setColumns(columns.filter(c => c !== column))} virtualizeRows estimateHeight={item => { let h = 40; @@ -1170,7 +1180,7 @@ function TeamCityBuild({ ), }); - const columnAnimation = use(ColumnAnimationContext); + const reorderAnimation = useReorderAnimation(); const columnAnimationEmulatorRef = useRef(null); useEffect(() => { @@ -1179,8 +1189,8 @@ function TeamCityBuild({ if (!columnAnimationEmulator || !table) return; - if (columnAnimation?.phase === 'initial') { - const {columnIndex} = columnAnimation; + if (reorderAnimation?.direction === 'columns' && reorderAnimation?.phase === 'initial') { + const {index: columnIndex} = reorderAnimation; const th = table.querySelector(`th:nth-child(${columnIndex + 1})`); if (th) { const tableLeft = table.getBoundingClientRect().left; @@ -1188,11 +1198,11 @@ function TeamCityBuild({ columnAnimationEmulator.style.left = `${thRect.left - tableLeft}px`; columnAnimationEmulator.style.width = `${thRect.width}px`; } - } else if (!columnAnimation) { + } else if (!reorderAnimation) { columnAnimationEmulator.style.removeProperty('left'); columnAnimationEmulator.style.removeProperty('width'); } - }, [columnAnimation]); + }, [reorderAnimation]); return ( <> @@ -1244,7 +1254,7 @@ function TeamCityBuild({
@@ -1492,3 +1502,289 @@ export const SimpleRerenderTest: TableStory<(typeof smallDataWithSelected)[numbe tags: ['!autodocs'], }; + +export const WithItemReorder: TableStory<(typeof smallDataSlice)[number]> = { + args: { + data: smallDataSlice, + getKey, + }, + + render(args) { + const [data, setData] = useState(args.data); + const [dragStyle, setDragStyle] = useState<'frame' | 'item-wide' | 'item-narrow'>('frame'); + const [itemDragState, setItemDragState] = useState<{index: number; state: DragState}>({ + index: -1, + state: undefined, + }); + + const columns = useMemo( + () => + [ + { + key: 'ID', + renderCell: ({id}, index) => { + const itemDrag = dragStyle !== 'frame'; + return ( + + setItemDragState({index, state})} + />{' '} + {id} + + ); + }, + }, + { + key: 'Country', + renderCell: ({country}) => {country}, + }, + { + key: 'City', + renderCell: ({city}) => {city}, + }, + { + key: 'URL', + renderCell: ({url}) => ( +
+ +
+ ), + }, + ] satisfies Column<(typeof smallDataSlice)[number]>[], + [dragStyle], + ); + + return ( + <> +
+ Drag style: + void}> + Frame + Item wide + Item narrow + +
+ +
reorderItems(data, fromIndex, insertionIndex, setData)} + renderItem={(_it, index) => { + const dragState = itemDragState.index === index ? itemDragState.state : undefined; + const isDragging = dragState != null && dragStyle !== 'frame'; + + return ( + <> + + + {isDragging && ( + + )} + + ); + }} + /> + + ); + }, + + parameters: { + screenshots: {skip: true}, + }, +}; + +const statuses = ['Open', 'In Progress', 'Fixed', 'Reopened', "Won't Fix", 'Duplicate'] as const; + +interface IssueWithStatus extends Issue { + status: (typeof statuses)[number]; + reason?: string; +} + +const issuesSliceWithStatus: readonly IssueWithStatus[] = issuesLongDataSlice.map(({id, priority, votes}) => { + const status = random(statuses); + const reason = + status === 'Reopened' + ? random(['Regression', 'New testcase found']) + : status === "Won't Fix" + ? random(['Not reproducible', 'Works as expected', 'Obsolete']) + : status === 'Duplicate' + ? random(issuesLongDataSlice).id + : undefined; + return { + id, + priority, + votes, + status, + reason, + }; +}); + +const issuesWithStatusColumns = [ + { + key: 'ID', + renderCell: ({id}, index) => ( + <> + {' '} + + {id} + + + ), + }, + { + key: 'Priority', + renderCell: ({priority}) => {priority}, + }, + { + key: 'Votes', + renderCell: ({votes}) => votes, + deletable: true, // To make story accessibility test happy + }, +] satisfies Column<(typeof issuesSliceWithStatus)[number]>[]; + +export const WithVirtualizationItemReorderCustom = { + args: {}, + + render() { + return ; + }, + + parameters: { + screenshots: {skip: true}, + ...noDocsParams, + }, +}; + +export const WithVirtualizationItemReorderScrollerCustom = { + args: {}, + + render() { + const scrollerRef = useRef(null); + + return ( +
+ +
+ ); + }, + + parameters: { + screenshots: {skip: true}, + ...noDocsParams, + }, +}; + +function IssuesWithStatusTable({scrollerRef}: {scrollerRef?: RefObject}) { + const [data, setData] = useState(issuesSliceWithStatus); + + return ( +
} + onItemReorder={(_it, fromIndex, insertionIndex) => reorderItems(data, fromIndex, insertionIndex, setData)} + virtualizeRows + scrollerRef={scrollerRef} + estimateHeight={item => { + let h = defaultRowHeight; + if (item.reason) { + h += 69; + } else { + h += 43; + } + return h; + }} + retentionMarginPx={2000} + /> + ); +} + +function CustomIssueItem({issue, index}: {issue: IssueWithStatus; index: number}) { + const {status, reason} = issue; + const mainRef = useRef(null); + const detailsRef = useRef(null); + + useItemVirtualization({ + index, + refs: [mainRef, detailsRef], + onIntersectionChange: (isIntersecting, _i, elements) => + isIntersecting.every(it => it === false) && elements.every(el => el?.isConnected) + ? elements.reduce((h, el) => h + el!.getBoundingClientRect().height, 0) + : undefined, + }); + + useReorderItemLayout({ + index, + getBounds: () => { + const start = mainRef.current?.getBoundingClientRect().top ?? 0; + const end = detailsRef.current?.getBoundingClientRect().bottom ?? start; + return {start, end}; + }, + }); + + const reorderAnimation = useReorderAnimation(); + const animationClass = + reorderAnimation?.index === index && reorderAnimation.direction === 'items' + ? reorderAnimation.className + : undefined; + + return ( + <> + + + +
property} + noHeader + aria-label='Build details' + /> + + + + ); +} diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx index 7199cc2f52f..f7c441f7b4c 100644 --- a/src/table/table.test.tsx +++ b/src/table/table.test.tsx @@ -4,6 +4,7 @@ import {fireEvent, render, screen} from '@testing-library/react'; import Table from './table'; import {DefaultItemRenderer} from './default-item-renderer'; +import {ItemReorderHandle} from './table-primitives'; import type {Column} from './table-props'; @@ -259,8 +260,8 @@ describe('Table basic scenarios', () => { columns={columns} getKey={getKey} columnEditing - onColumnDelete={columnIndex => { - setColumns(previousColumns => previousColumns.filter((_, i) => i !== columnIndex)); + onColumnDelete={column => { + setColumns(previousColumns => previousColumns.filter(c => c !== column)); }} /> ); @@ -284,12 +285,12 @@ describe('Table basic scenarios', () => { columns={columns} getKey={getKey} columnEditing - onColumnReorder={(fromIndex, insertionIndex) => { + onColumnReorder={(column, fromIndex, insertionIndex) => { setColumns(previousColumns => { const newColumns = [...previousColumns]; - const [moved] = newColumns.splice(fromIndex, 1); + newColumns.splice(fromIndex, 1); const nextIndex = fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex; - newColumns.splice(nextIndex, 0, moved); + newColumns.splice(nextIndex, 0, column); return newColumns; }); }} @@ -307,6 +308,64 @@ describe('Table basic scenarios', () => { expect(headers).to.deep.equal(['Id', 'Capital', 'Country', 'Wikipedia']); }); + it('moves a row down with keyboard on reorder handle', () => { + function ReorderableRowsTable() { + const [data, setData] = useState(countries); + const [columns] = useState( + () => + [ + { + ...baseColumns[0], + renderCell: ({id, country}, index) => ( + <> + {id} + + ), + }, + ...baseColumns.slice(1), + ] satisfies Column[], + ); + + return ( +
{ + setData(previousData => { + const newData = [...previousData]; + newData.splice(fromIndex, 1); + const nextIndex = fromIndex < insertionIndex ? insertionIndex - 1 : insertionIndex; + newData.splice(nextIndex, 0, item); + return newData; + }); + }} + /> + ); + } + + const {container} = render(); + + const initialFirst4CapitalCities = ['Oslo', 'Stockholm', 'Dublin', 'Amsterdam']; + function getActualFirst4CapitalCities() { + return [...container.querySelectorAll('tbody tr td:nth-child(3)')].map(td => td.textContent?.trim()).slice(0, 4); + } + + expect(getActualFirst4CapitalCities()).to.deep.equal(initialFirst4CapitalCities); + + const swedenReorderButton = screen.getByTestId('Sweden'); + swedenReorderButton.focus(); + fireEvent.keyDown(swedenReorderButton, {key: 'ArrowDown'}); + + const capitalCitiesAfterMove = [ + initialFirst4CapitalCities[0], + initialFirst4CapitalCities[2], + initialFirst4CapitalCities[1], + initialFirst4CapitalCities[3], + ]; + expect(getActualFirst4CapitalCities()).to.deep.equal(capitalCitiesAfterMove); + }); + it('changes thead className when columnEditing is controlled externally', () => { function ExternallyControlledColumnEditingTable() { const [columnEditing, setColumnEditing] = useState(false); diff --git a/src/table/table.tsx b/src/table/table.tsx index 45f2f92efdf..8ee63fb258d 100644 --- a/src/table/table.tsx +++ b/src/table/table.tsx @@ -1,16 +1,16 @@ -import React, {type ComponentPropsWithRef, Fragment, useCallback, useRef} from 'react'; +import React, {type ComponentPropsWithRef, Fragment, useRef} from 'react'; import classNames from 'classnames'; -import {IntersectionObserverContext} from '../global/intersection-observer-context'; -import {CollapseItemIntoSpacerContext, SpacerRow, useVirtualItems, type VirtualItem} from './internal/virtual-items'; +import {SpacerRow, useVirtualItems, VirtualizationContext, type VirtualItem} from './internal/virtualization'; import {DefaultItemRenderer} from './default-item-renderer'; -import {ColumnAnimationContext, defaultRowHeight, TablePropsContext} from './table-const'; +import {defaultRowHeight, TablePropsContext} from './table-const'; import {focusWithTemporaryTabIndex} from '../global/focus-with-temporary-tabindex'; -import {useColumnAnimation} from './internal/column-animation'; +import {ReorderAnimationContext, useReorderAnimationContextValue} from './internal/reorder-animation-context'; import {useComposedRef} from '../global/compose-refs'; import {TableHeader} from './internal/table-header'; import {keyboardFocusableAttrName} from './table-primitives'; import {isWithinNavigableElement} from '../global/is-within-navigable-element'; +import {ReorderLayoutContext, useReorderLayoutContextValue} from './internal/reorder-layout-context'; import type {TableProps} from './table-props'; @@ -105,7 +105,7 @@ import styles from './table.css'; * Note that for accessibility reasons, you should have a cell with a checkbox * to display and toggle item selection. * - * ## Rows focus + * ## Row focus * * The table implements the ["roving tabindex"](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets#technique_1_roving_tabindex) * technique to focus rows with the up/down arrow keys. @@ -173,6 +173,23 @@ import styles from './table.css'; * expected to update `columns` by moving the corresponding column to the * new position. * + * ## Item reorder + * + * To allow the user to reorder rows by dragging: + * + * - Place `ItemReorderHandle` (from `table/table-primitives`) anywhere inside + * a cell. It renders a drag icon button the user can grab to reorder the row. + * - Handle `TableProps.onItemReorder`. It is expected to update `data` by + * moving the item to the new position. + * - Optionally, set `TableProps.canReorderItem` to restrict which positions + * an item may be dropped into. + * + * By default, dragging shows a drag frame (a border around the dragged row) + * and an insertion indicator (a line between rows showing where the item will + * land). To implement fully custom drag visuals, set `noDragFrame` and + * `noHandleTranslate` on `ItemReorderHandle` and use its `onUserDrag` callback + * to track the drag lifecycle. + * * ## Row virtualization * * To render only rows near the viewport while replacing off-screen rows with @@ -214,11 +231,20 @@ import styles from './table.css'; * set the `noItemVirtualization` prop to `true`, otherwise it will also try * to control the virtualization, possibly reporting incorrect item height. * - * ### Column reorder animation + * ### Item reorder * - * Default-rendered rows highlight the column that was just reordered. To apply - * the same animation to your custom-rendered rows, use `ColumnAnimationContext` - * (from `table/table-const`) to get information about the currently animated column. + * If `TableProps.onItemReorder` is set and your item spans multiple rows, call + * `useReorderItemLayout()` (from `table/reorder-item-layout`) to register the + * item's boundaries so the insertion indicator and insertion point calculation + * are correct. If `DefaultItemRenderer` is included inside your custom renderer, + * set its `noReorderLayout` prop to `true` to prevent double registration. + * + * ### Reorder animation + * + * After a column or item is reordered, the table briefly highlights the moved + * element. To apply the same animation in your custom-rendered rows, use + * `useReorderAnimation()` (from `table/reorder-animation`) to get information + * about the currently animated column or item. */ export default function Table(props: TableProps & ComponentPropsWithRef<'table'>) { const { @@ -231,6 +257,9 @@ export default function Table(props: TableProps & ComponentPropsWithRef<'t onColumnDelete, onColumnReorder, noColumnReorderAnimation, + canReorderItem, + onItemReorder, + noItemReorderAnimation, renderItem, virtualizeRows = false, scrollerRef, @@ -255,24 +284,15 @@ export default function Table(props: TableProps & ComponentPropsWithRef<'t const localRef = useRef(null); - const {virtualItems, intersectionObserverHandle, collapseItemIntoSpacer} = useVirtualItems({ - enabled: virtualizeRows, - data, - scrollerRef, - tableRef: localRef, - estimateHeight, - lookaheadPx, - retentionMarginPx, - minScrollAndResizeDeltaPx, - }); - - const {columnAnimation, expectColumnReorder} = useColumnAnimation({ - disabled: noColumnReorderAnimation, + const reorderAnimationContextValue = useReorderAnimationContextValue({ + noColumnReorderAnimation, + noItemReorderAnimation, tableRef: localRef, + data, columns, }); - const handleRowNavigation = useCallback((e: React.KeyboardEvent) => { + function handleRowNavigation(e: React.KeyboardEvent) { if (e.defaultPrevented || isWithinNavigableElement(e.target)) return; const arrowUp = e.key === 'ArrowUp'; @@ -296,17 +316,30 @@ export default function Table(props: TableProps & ComponentPropsWithRef<'t return; } } - }, []); + } + + const {virtualItems, virtualizationContextValue} = useVirtualItems({ + enabled: virtualizeRows, + data, + scrollerRef, + tableRef: localRef, + estimateHeight, + lookaheadPx, + retentionMarginPx, + minScrollAndResizeDeltaPx, + }); + + const itemReorderLayoutContextValue = useReorderLayoutContextValue(); return ( }> - +
- - - - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} - + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + + + {(virtualizeRows ? virtualItems : data).map((item, index) => { let dataItem: T; let dataItemIndex: number; @@ -335,11 +368,11 @@ export default function Table(props: TableProps & ComponentPropsWithRef<'t ); })} - - - + + +
- + ); } diff --git a/src/util-stories.ts b/src/util-stories.ts index abe3619ba9f..64b8f89da5c 100644 --- a/src/util-stories.ts +++ b/src/util-stories.ts @@ -21,11 +21,11 @@ export function createRandom(seed: bigint): { /** * Up to random n items (in random order) from the array */ - (array: T[], n: number): T[]; + (array: readonly T[], n: number): T[]; /** * Random item from the array */ - (array: T[]): T; + (array: readonly T[]): T; } { const u64Mask = 2n ** 64n - 1n; const u64Range = 2n ** 64n; From 5b176fd5df518427ab6c8724ecc517f6e2b02eae Mon Sep 17 00:00:00 2001 From: Aleksei Berezkin <16083785+aleksei-berezkin@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:36:23 +0200 Subject: [PATCH 09/36] RG-2786 Fixed a merge --- src/alert/alert.css | 3 +-- src/dropdown-menu/dropdown-menu.tsx | 3 +-- src/select/select.tsx | 14 ++++++-------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/alert/alert.css b/src/alert/alert.css index d50788b503f..e08c23fdc3e 100644 --- a/src/alert/alert.css +++ b/src/alert/alert.css @@ -33,10 +33,9 @@ } .alertInline { + margin: var(--ring-unit); /* Keep the margin box within narrow parents despite the fixed width */ - max-width: calc(100% - var(--ring-unit) * 2); - margin: var(--ring-unit); } .error { diff --git a/src/dropdown-menu/dropdown-menu.tsx b/src/dropdown-menu/dropdown-menu.tsx index c65cf677d23..3f2f7332de7 100644 --- a/src/dropdown-menu/dropdown-menu.tsx +++ b/src/dropdown-menu/dropdown-menu.tsx @@ -80,8 +80,7 @@ function renderDropdownMenuChildren({children, popupMenuProps}: DropdownMenuC } type OnSelectHandler = - | ((item: ListDataItem, event: Event | SyntheticEvent, params?: SelectHandlerParams) => void) - | undefined; + ((item: ListDataItem, event: Event | SyntheticEvent, params?: SelectHandlerParams) => void) | undefined; export interface DropdownMenuProps extends Omit { ref?: React.Ref | null>; diff --git a/src/select/select.tsx b/src/select/select.tsx index 7d98646f746..3141f9b5011 100644 --- a/src/select/select.tsx +++ b/src/select/select.tsx @@ -310,14 +310,12 @@ function getListItems( } // Ignore item if it's multiple and is already selected - if ( - !( - props.multiple && - typeof props.multiple === 'object' && - props.multiple.removeSelectedItems && - state.multipleMap?.[item.key] - ) - ) { + if (!( + props.multiple && + typeof props.multiple === 'object' && + props.multiple.removeSelectedItems && + state.multipleMap?.[item.key] + )) { filteredData.push(item); } } From ddafe0e06b73c6594f4533caafe73b3963569385 Mon Sep 17 00:00:00 2001 From: JetBrains Ring UI Automation Date: Thu, 23 Jul 2026 12:14:07 +0000 Subject: [PATCH 10/36] 8.0.0-beta.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8d418f7bc2..ff7e7e866bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.4", + "version": "8.0.0-beta.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.4", + "version": "8.0.0-beta.5", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index afca8f5c72d..61907b400a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.4", + "version": "8.0.0-beta.5", "description": "JetBrains UI library", "author": { "name": "JetBrains" From 8aeae7a9f88a5f2ac6c275297aed88e51df0d98a Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Thu, 6 Aug 2026 22:19:02 +0200 Subject: [PATCH 11/36] Drop React 18 compatibility build --- .teamcity/src/Project.kt | 1 - .teamcity/src/tests/AllChecks.kt | 3 -- .teamcity/src/tests/React18Compat.kt | 55 ---------------------------- 3 files changed, 59 deletions(-) delete mode 100644 .teamcity/src/tests/React18Compat.kt diff --git a/.teamcity/src/Project.kt b/.teamcity/src/Project.kt index f0cdb8c3ca6..0f4d2c75ead 100644 --- a/.teamcity/src/Project.kt +++ b/.teamcity/src/Project.kt @@ -18,7 +18,6 @@ object Project : Project({ buildType(SecurityAudit) buildType(UnpublishSpecificVersion) buildType(GeminiTests) - buildType(React18Compat) buildType(QodanaAnalysis) buildType(UnitTestsAndBuild) buildType(Publish) diff --git a/.teamcity/src/tests/AllChecks.kt b/.teamcity/src/tests/AllChecks.kt index 9ba956cb05f..ced4b426080 100644 --- a/.teamcity/src/tests/AllChecks.kt +++ b/.teamcity/src/tests/AllChecks.kt @@ -81,8 +81,5 @@ object AllChecks : BuildType({ snapshot(ConsoleErrors) { onDependencyCancel = FailureAction.ADD_PROBLEM } - snapshot(React18Compat) { - onDependencyCancel = FailureAction.ADD_PROBLEM - } } }) diff --git a/.teamcity/src/tests/React18Compat.kt b/.teamcity/src/tests/React18Compat.kt deleted file mode 100644 index 1ed2eb4bf66..00000000000 --- a/.teamcity/src/tests/React18Compat.kt +++ /dev/null @@ -1,55 +0,0 @@ -package tests - -import jetbrains.buildServer.configs.kotlin.BuildType -import jetbrains.buildServer.configs.kotlin.DslContext -import jetbrains.buildServer.configs.kotlin.buildSteps.script - -/** - * Runs the Collapse/CollapsibleGroup test suites against the React 18 runtime. - * - * Collapse serializes the `inert` attribute differently for React 18 (empty-string - * attribute form) and React 19 (real boolean), and the main test run — pinned to - * React 19 — never exercises the React 18 branch even though `react >=18` is a - * supported peer range. Runtime only: the repository is type-checked against the - * React 19 typings, so no React 18 type packages are involved. - * - * TODO drop this build in develop-8.0 — Ring UI 8.0 supports React 19 only - */ -object React18Compat : BuildType({ - name = "React 18 runtime compatibility" - - allowExternalStatus = true - - params { - param("env.NODE_OPTIONS", "--max-old-space-size=8192") - } - - vcs { - root(DslContext.settingsRoot) - } - - steps { - script { - name = "Run Collapse tests on React 18" - scriptContent = """ - #!/bin/bash - set -e -x - - node -v - npm -v - - chown -R root:root . # See https://github.com/npm/cli/issues/4589 - mkdir -p node_modules - npm install - npm install --no-save react@18 react-dom@18 - npx vitest run src/collapse src/collapsible-group - """.trimIndent() - dockerImage = "registry.jetbrains.team/p/ij/docker-hub/node:22.22.3" - } - } - - requirements { - exists("docker.version") - contains("docker.server.osType", "linux") - } -}) From 5aefdccdefa56c6381daec7eccfc9c81c57f41dd Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Thu, 6 Aug 2026 22:21:16 +0200 Subject: [PATCH 12/36] Use shared CSS duration parser in Collapse --- src/collapse/collapse-content.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/collapse/collapse-content.tsx b/src/collapse/collapse-content.tsx index 7bed7fad107..a786b22075b 100644 --- a/src/collapse/collapse-content.tsx +++ b/src/collapse/collapse-content.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import dataTests from '../global/data-tests'; import {getRect} from '../global/dom'; +import {parseCssDuration} from '../global/parse-css-duration'; import {toPx} from './utils'; import CollapseContext from './collapse-context'; import {COLLAPSE_CONTENT_TEST_ID, COLLAPSE_CONTENT_CONTAINER_TEST_ID} from './consts'; @@ -74,8 +75,7 @@ export const CollapseContent: React.FC> = ({ // Armed once per collapse toggle from the --duration committed to the DOM — the value // the running transition actually uses — so it can neither undercut a transition started // from a stale height nor be restarted by content resizes or duration changes mid-collapse - // TODO merge with global/parse-css-duration when this lands in develop-8.0 - const cssDuration = parseFloat(container?.style.getPropertyValue('--duration') || '') || 0; + const cssDuration = parseCssDuration(container?.style.getPropertyValue('--duration') || ''); const fallbackTimeout = window.setTimeout(finalizeCollapse, cssDuration + HIDE_FALLBACK_EXTRA_DELAY); return () => { @@ -138,7 +138,7 @@ export const CollapseContent: React.FC> = ({ // tree, but descendants can override it with visibility: visible — inert cannot be escaped. // Both are rendered in JSX so server-rendered collapsed markup is protected before hydration. style={contentHidden ? {visibility: 'hidden'} : undefined} - inert={contentInert || undefined} + inert={contentInert} > {keepMounted || contentVisible ? children : null}
From 1b5fc7babddd608ae8ace363277f265b08895753 Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 7 Aug 2026 12:40:28 +0200 Subject: [PATCH 13/36] Align Storybook package versions --- package-lock.json | 106 +++++++++++++++++----------------------------- package.json | 4 +- 2 files changed, 42 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6427657eb2f..bb20eaa4df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,8 +78,8 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.3", "@storybook/addon-a11y": "10.4.6", - "@storybook/addon-docs": "^10.4.6", - "@storybook/addon-themes": "^10.4.6", + "@storybook/addon-docs": "10.4.6", + "@storybook/addon-themes": "10.4.6", "@storybook/csf": "^0.1.13", "@storybook/react-webpack5": "10.4.6", "@storybook/test-runner": "^0.24.4", @@ -11082,15 +11082,16 @@ } }, "node_modules/@storybook/addon-docs": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.4.tgz", - "integrity": "sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.6.tgz", + "integrity": "sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==", "dev": true, + "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.5.4", + "@storybook/csf-plugin": "10.4.6", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.5.4", + "@storybook/react-dom-shim": "10.4.6", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -11101,7 +11102,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.4" + "storybook": "^10.4.6" }, "peerDependenciesMeta": { "@types/react": { @@ -11110,10 +11111,11 @@ } }, "node_modules/@storybook/addon-themes": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.5.4.tgz", - "integrity": "sha512-g5uTI7/hJxwXEzSFkPBBIJ/jX6l7rYG1CotcG7+FIvZ2PPTFyph/5MmMvS+wtisppZsSprquX3RnYWwmn1bcZA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.4.6.tgz", + "integrity": "sha512-80d622oB9xWZs3VH4uywkLOA5L2DAx04lVouvCM4XH+pLnJElidoylOLm3i3ByvlGkRjCbB27OUVsW94IgyDrw==", "dev": true, + "license": "MIT", "dependencies": { "ts-dedent": "^2.0.0" }, @@ -11122,7 +11124,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "10.5.4" + "storybook": "^10.4.6" } }, "node_modules/@storybook/builder-webpack5": { @@ -11188,10 +11190,11 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz", - "integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.6.tgz", + "integrity": "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==", "dev": true, + "license": "MIT", "dependencies": { "unplugin": "^2.3.5" }, @@ -11202,7 +11205,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "10.5.4", + "storybook": "^10.4.6", "vite": "*", "webpack": "*" }, @@ -11353,10 +11356,11 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz", - "integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz", + "integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==", "dev": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" @@ -11366,7 +11370,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.4" + "storybook": "^10.4.6" }, "peerDependenciesMeta": { "@types/react": { @@ -11404,32 +11408,6 @@ } } }, - "node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": { - "version": "10.4.6", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz", - "integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.6" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@storybook/react/node_modules/react-docgen": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", @@ -39897,6 +39875,7 @@ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", @@ -39912,6 +39891,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -48191,24 +48171,24 @@ } }, "@storybook/addon-docs": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.4.tgz", - "integrity": "sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.6.tgz", + "integrity": "sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==", "dev": true, "requires": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.5.4", + "@storybook/csf-plugin": "10.4.6", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.5.4", + "@storybook/react-dom-shim": "10.4.6", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" } }, "@storybook/addon-themes": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.5.4.tgz", - "integrity": "sha512-g5uTI7/hJxwXEzSFkPBBIJ/jX6l7rYG1CotcG7+FIvZ2PPTFyph/5MmMvS+wtisppZsSprquX3RnYWwmn1bcZA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.4.6.tgz", + "integrity": "sha512-80d622oB9xWZs3VH4uywkLOA5L2DAx04lVouvCM4XH+pLnJElidoylOLm3i3ByvlGkRjCbB27OUVsW94IgyDrw==", "dev": true, "requires": { "ts-dedent": "^2.0.0" @@ -48264,9 +48244,9 @@ } }, "@storybook/csf-plugin": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz", - "integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.6.tgz", + "integrity": "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==", "dev": true, "requires": { "unplugin": "^2.3.5" @@ -48326,12 +48306,6 @@ "react-docgen-typescript": "^2.2.2" }, "dependencies": { - "@storybook/react-dom-shim": { - "version": "10.4.6", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz", - "integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==", - "dev": true - }, "react-docgen": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", @@ -48368,9 +48342,9 @@ } }, "@storybook/react-dom-shim": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz", - "integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz", + "integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==", "dev": true }, "@storybook/react-webpack5": { diff --git a/package.json b/package.json index 61907b400a6..6419d826b9f 100644 --- a/package.json +++ b/package.json @@ -118,8 +118,8 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.3", "@storybook/addon-a11y": "10.4.6", - "@storybook/addon-docs": "^10.4.6", - "@storybook/addon-themes": "^10.4.6", + "@storybook/addon-docs": "10.4.6", + "@storybook/addon-themes": "10.4.6", "@storybook/csf": "^0.1.13", "@storybook/react-webpack5": "10.4.6", "@storybook/test-runner": "^0.24.4", From 822618d820a0fa759fba9b75530b08a1f63ad0b6 Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 7 Aug 2026 13:17:48 +0200 Subject: [PATCH 14/36] Update visual regression screenshots --- .../with expand and focus/with expand and focus-dark.png | 4 ++-- .../with expand and focus/with expand and focus-light.png | 4 ++-- .../with virtualization in scroller bottom-dark.png | 4 ++-- .../with virtualization in scroller bottom-light.png | 4 ++-- .../with virtualization in scroller top-dark.png | 4 ++-- .../with virtualization in scroller top-light.png | 4 ++-- .../testplane/firefox/components/button/basic/basic-dark.png | 4 ++-- .../firefox/components/button/basic/basic-focus active.png | 4 ++-- .../testplane/firefox/components/button/basic/basic.png | 4 ++-- .../components/icon/all icons list/all icons list-dark.png | 4 ++-- .../firefox/components/icon/all icons list/all icons list.png | 4 ++-- .../with expand and focus/with expand and focus-dark.png | 4 ++-- .../with expand and focus/with expand and focus-light.png | 4 ++-- .../with virtualization in scroller bottom-dark.png | 4 ++-- .../with virtualization in scroller bottom-light.png | 4 ++-- .../with virtualization in scroller top-dark.png | 4 ++-- .../with virtualization in scroller top-light.png | 4 ++-- 17 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-dark.png b/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-dark.png index a93f3fbf47d..ba718f6594c 100644 --- a/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-dark.png +++ b/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b98fc9b239917a26843be0b8586d021c7b59a698e44ae85ab2e6213a4d35cee3 -size 13498 +oid sha256:69df2c825d69cf4a00dfdd974f9544f5e7fc8aca05a7da604feb410ffb7a20fa +size 13499 diff --git a/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-light.png b/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-light.png index 8bd217cdfa5..2f54bd3f0c7 100644 --- a/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-light.png +++ b/packages/screenshots/testplane/chrome/components/table/with expand and focus/with expand and focus-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea2c5bcd39952aced0110cd86f941ee076190a5868cf33d8ed9dffb75e18d92b -size 13316 +oid sha256:c7f7e074007f29db5ccd32653dc40867897bd75f05c7598b6628b884961d6727 +size 13318 diff --git a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png index 52eb272587a..98e2669cf51 100644 --- a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png +++ b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ec916620e229fc181d1fe532eda55ae6df4b584beb1cafe5dba9e9086b083080 -size 10852 +oid sha256:83c1ef07ec1346aa7375fe235f38b3b896a8687ea8b97c67463e0083da54e9ac +size 10857 diff --git a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png index e3ac0dad7cc..0803ac74305 100644 --- a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png +++ b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3b69c3c7797dd1b898bc6db1ffb6805b7d586102677a042c58d32d986afa151 -size 10712 +oid sha256:2bad68658b13bb07cef2cd6bbdea518a630fb6734c9cf50e4c71782f1a0ee308 +size 10711 diff --git a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png index f6ead314740..934b7a60245 100644 --- a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png +++ b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa8354f68a9c98115fb57c95450524ffdb37f6a844be872132e1c5bc433fbd08 -size 10586 +oid sha256:fd1f66950665a52baceba21c477b3a68bf685b00ae6e3f2e9912789233b43718 +size 10582 diff --git a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png index 0838a7f6e86..61b9cffc04c 100644 --- a/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png +++ b/packages/screenshots/testplane/chrome/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4711d28cd303864a0ec6ef5abd26ef57ea6393d02676a68d5487712297baf53a -size 10686 +oid sha256:19245676fa9921adea8d07ed235962b4fdf664fae505a115256e7af844674cc5 +size 10690 diff --git a/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png b/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png index 77268740920..5849a90ba7c 100644 --- a/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png +++ b/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f4a90e4c38f2de15b154f56728fffc4eeb0efe074eca22700371e042f5bc755 -size 847595 +oid sha256:a32d60e1cf2e10d33b301c3ac7efeab3fb333001141b2a16056b599a24e7614b +size 847611 diff --git a/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png b/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png index af2a21b9092..5e1dbda4c3c 100644 --- a/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png +++ b/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7977e86587c1c6b51f6b23091dc47f64c68b968ea7c7444d542b355e2c88e5c -size 826095 +oid sha256:aebcc907f7534857f5ba76cb293163ff4140172f1fd09713f31f36757469d953 +size 826113 diff --git a/packages/screenshots/testplane/firefox/components/button/basic/basic.png b/packages/screenshots/testplane/firefox/components/button/basic/basic.png index bc13e030572..b4f01d55570 100644 --- a/packages/screenshots/testplane/firefox/components/button/basic/basic.png +++ b/packages/screenshots/testplane/firefox/components/button/basic/basic.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e86a29e082838d4a6d13d3c7c593a2124722fba345bee39e05240da6810c28f9 -size 825865 +oid sha256:29ad3407a06e3259a924b1a6f166f92ff1205b57e25007100acc9592343edcca +size 825878 diff --git a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png index c95d573b3e7..709d9bd42bf 100644 --- a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png +++ b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8dab307c4aa60f1cc0c33db89dd354192cece6bc2ac67c5b02a31a934d52f17d -size 854865 +oid sha256:cd613ba5056d386522c5b9ec422bbc510e4d8aa16184546325d109f402b9466a +size 854842 diff --git a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png index e1f63ca5e33..08b3b144fd9 100644 --- a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png +++ b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4008b05084ab01b3ddb9648641115e7448244c18805a5d469c53cd32de1e2b6a -size 826898 +oid sha256:0cf63bb3b174f6d24bdd518628366b87bb8636a36772ede0ba45e33ef26ea8a6 +size 826879 diff --git a/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-dark.png b/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-dark.png index fb163a87c0b..466636bea2f 100644 --- a/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-dark.png +++ b/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0337773b37808aa426f6f943f0f99120b36878b6e6342edfacc72f2c08dad5bd -size 25389 +oid sha256:c39f588e43b8090719233297398f2eabe3e1d2fac9d80ec2761f87bbe49f27e6 +size 25392 diff --git a/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-light.png b/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-light.png index 80b531d260d..ff5f8bc39d5 100644 --- a/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-light.png +++ b/packages/screenshots/testplane/firefox/components/table/with expand and focus/with expand and focus-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cfe0ec1fe547814e1fcc3bc51bab72eb2d31c5c869581375ebd7168c5a8a863d -size 24621 +oid sha256:ca8c9a4037b27d6fbe676a3bfed5b8b5255310e6d030f1e9a875e3cfa186a171 +size 24641 diff --git a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png index edfee34c997..902d56f16af 100644 --- a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png +++ b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9fb204b4a70bc1bb8ac7eeb3696d38fb4d68e7f57e9906decda4f7c7a5441fb5 -size 19752 +oid sha256:02157aef724796bd5569fbf05fb290daf0b93fd129b5deb6c5d6763a9c86123d +size 19761 diff --git a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png index 05fd08a3092..c7200ac4f5d 100644 --- a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png +++ b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller bottom/with virtualization in scroller bottom-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bc46d272ac7ff31cb4e8f61a1768d05f6f199d28e3c1c8a177572f1be739be4 -size 18827 +oid sha256:47b8fcae6bef653cc362cb011922762bf7e8e6e6d7c08d2876182e0e924d42ac +size 18843 diff --git a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png index e52af33bd58..587559cf48c 100644 --- a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png +++ b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:046504c8bbaef0473c47e022f92d19327c478596ff21f4dc4a0388ad31e2f7ba -size 17751 +oid sha256:5a599f3a916ecf79101a60b2e57117cf2af806148ba07c98f1ab575c77e8eb5d +size 17778 diff --git a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png index ee564464130..9f2375881b1 100644 --- a/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png +++ b/packages/screenshots/testplane/firefox/components/table/with virtualization in scroller top/with virtualization in scroller top-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:682f1aad21e053862a14402c48d8de51baabd23b3d9b09c334a1dc025a7179d6 -size 17299 +oid sha256:ec24b701311de7ab8d451480450b9cf99bca68a1edf43906e0ccbdb175101953 +size 17340 From 75a1e7fb5b16aa22793c0f62112d48de8f9f1ede Mon Sep 17 00:00:00 2001 From: JetBrains Ring UI Automation Date: Fri, 7 Aug 2026 11:49:18 +0000 Subject: [PATCH 15/36] 8.0.0-beta.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb20eaa4df6..9926cb2eb02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.5", + "version": "8.0.0-beta.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.5", + "version": "8.0.0-beta.6", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index 6419d826b9f..e0762a5af3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.5", + "version": "8.0.0-beta.6", "description": "JetBrains UI library", "author": { "name": "JetBrains" From a3619bdb6c681a8d13b42e044771071274ac97d8 Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 7 Aug 2026 13:57:40 +0200 Subject: [PATCH 16/36] Remove APIs deprecated for 8.0 --- CHANGELOG.md | 9 + src/avatar-stack/avatar-stack.tsx | 3 +- src/avatar/avatar-info.tsx | 2 - src/avatar/avatar-size.ts | 4 - src/avatar/avatar.tsx | 12 - src/avatar/fallback-avatar.tsx | 28 - src/button/button.css | 8 +- src/button/button.stories.tsx | 36 +- src/button/button.tsx | 17 +- .../collapsible-group.test.tsx | 28 - src/content-layout/content-layout.css | 109 --- src/content-layout/content-layout.stories.tsx | 166 ---- src/content-layout/content-layout.test.tsx | 45 - src/content-layout/content-layout.tsx | 64 -- src/content-layout/sidebar.tsx | 95 -- src/expand/collapsible-group.css | 76 -- src/expand/collapsible-group.test.tsx | 34 - src/expand/collapsible-group.tsx | 23 - src/global/variables.css | 10 - src/global/variables.interface.ts | 4 - src/global/variables.stories.tsx | 3 - src/global/variables_dark.css | 7 - src/grid/col.tsx | 80 -- src/grid/grid.css | 920 ------------------ src/grid/grid.stories.tsx | 283 ------ src/grid/grid.test.tsx | 146 --- src/grid/grid.tsx | 31 - src/grid/row.tsx | 90 -- src/icon/icon.css | 27 - src/icon/icon.tsx | 22 +- src/legacy-table/table.stories.tsx | 77 +- src/list/consts.ts | 1 - src/list/list.stories.tsx | 15 +- src/list/list.tsx | 4 - .../__mocks__/old-browsers-message.js | 1 - .../old-browsers-message-stop.ts | 2 - .../old-browsers-message.stories.tsx | 5 +- .../old-browsers-message.ts | 9 - src/select/select.tsx | 6 +- 39 files changed, 81 insertions(+), 2421 deletions(-) delete mode 100644 src/content-layout/content-layout.css delete mode 100644 src/content-layout/content-layout.stories.tsx delete mode 100644 src/content-layout/content-layout.test.tsx delete mode 100644 src/content-layout/content-layout.tsx delete mode 100644 src/content-layout/sidebar.tsx delete mode 100644 src/expand/collapsible-group.css delete mode 100644 src/expand/collapsible-group.test.tsx delete mode 100644 src/expand/collapsible-group.tsx delete mode 100644 src/grid/col.tsx delete mode 100644 src/grid/grid.css delete mode 100644 src/grid/grid.stories.tsx delete mode 100644 src/grid/grid.test.tsx delete mode 100644 src/grid/grid.tsx delete mode 100644 src/grid/row.tsx delete mode 100644 src/old-browsers-message/old-browsers-message-stop.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5d66f9d0f..57d7d74f2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ - The file `table/selection.ts` was moved and renamed to `global/table-selection.ts`. - Changed the minimum supported React version to 19.2.0. - Removed the `useEventCallback()` custom hook; use the `useEffectEvent()` React hook instead. +- Removed the deprecated `Grid`, `Row`, `Col`, `ContentLayout`, and `Sidebar` components; use CSS flexbox, CSS grid, or another layout library instead. +- Removed the deprecated `Button` `text` prop; use `inline` instead. +- Removed the deprecated `Icon` `loading` prop and `ListDataItemType.HINT`. +- Removed the deprecated `List` `visible` prop; visibility is detected automatically with `IntersectionObserver`. +- Removed the deprecated `Avatar` sizes 18 and 48; use another supported size instead. +- Removed the deprecated `old-browsers-message` `stop` export. +- Removed the deprecated `expand/collapsible-group` component and CSS import aliases; import them from `collapsible-group/collapsible-group` instead. +- Removed the deprecated `--ring-border-disabled-active-color`, `--ring-action-link-color`, and `--ring-button-primary-background-color` CSS variables; use `--ring-border-hover-color`, `--ring-link-color`, and `--ring-main-color`, respectively. +- Removed the deprecated `--ring-pinned-shadow-color`, `--ring-hint-color`, and `--ring-button-loader-background` CSS variables. ## [7.0.121] - Added `keepMounted` prop to `Collapse` and `CollapsibleGroup` that keeps collapsed content mounted (hidden via `visibility: hidden` and `inert`) instead of unmounting it, preserving local state, subscriptions and iframes. Limitations: content rendered through portals (e.g. `Popup`) is not hidden, and hidden form controls still participate in form validation — disable them while collapsed if needed. diff --git a/src/avatar-stack/avatar-stack.tsx b/src/avatar-stack/avatar-stack.tsx index ebadb8dc407..378c495d9c9 100644 --- a/src/avatar-stack/avatar-stack.tsx +++ b/src/avatar-stack/avatar-stack.tsx @@ -30,8 +30,7 @@ export default function AvatarStack({ }: AvatarProps) { const [dropdownOpen, setDropdownOpen] = useState(false); - const sizeClass = - size !== Size.Size16 && size !== Size.Size18 && size !== Size.Size48 ? styles[`size${size}`] : undefined; + const sizeClass = size !== Size.Size16 ? styles[`size${size}`] : undefined; return (
= { [Size.Size16]: 9, - [Size.Size18]: 9, [Size.Size20]: 9, [Size.Size24]: 11, [Size.Size28]: 12, [Size.Size32]: 14, [Size.Size40]: 16, - [Size.Size48]: 16, [Size.Size56]: 22, }; diff --git a/src/avatar/avatar-size.ts b/src/avatar/avatar-size.ts index 502e65acef2..97ecb602cc1 100644 --- a/src/avatar/avatar-size.ts +++ b/src/avatar/avatar-size.ts @@ -4,14 +4,10 @@ export enum Size { Size16 = 16, - /** @deprecated */ - Size18 = 18, Size20 = 20, Size24 = 24, Size28 = 28, Size32 = 32, Size40 = 40, - /** @deprecated */ - Size48 = 48, Size56 = 56, } diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 07d8ee8ada6..787a6ee47f6 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -1,10 +1,8 @@ import {PureComponent, type ImgHTMLAttributes, type ReactNode} from 'react'; import classNames from 'classnames'; -import deprecate from 'util-deprecate'; import {encodeURL, isDataURI, parseQueryString} from '../global/url'; import {getPixelRatio} from '../global/dom'; -import memoize from '../global/memoize'; import FallbackAvatar from './fallback-avatar'; import {Size} from './avatar-size'; import AvatarInfo from './avatar-info'; @@ -25,13 +23,6 @@ export interface AvatarProps extends ImgHTMLAttributes { skipParams?: boolean | null | undefined; } -const warnSize = memoize((size: Size) => - deprecate( - () => {}, - `Avatar: Size${size} is deprecated and will be removed in 8.0. The supported sizes are: Size20, Size24, Size28, Size32, Size40.`, - ), -); - export default class Avatar extends PureComponent { static defaultProps = { dpr: getPixelRatio(), @@ -55,9 +46,6 @@ export default class Avatar extends PureComponent { render() { const {size, url, dpr, style, round, subavatar, subavatarSize, username, info, skipParams, ...restProps} = this.props; - if ([Size.Size18, Size.Size48].includes(size)) { - warnSize(size)(); - } const sizeString = `${size}px`; const subavatarSizeString = `${subavatarSize}px`; const styleObj = { diff --git a/src/avatar/fallback-avatar.tsx b/src/avatar/fallback-avatar.tsx index 9454767e2ac..6963929defb 100644 --- a/src/avatar/fallback-avatar.tsx +++ b/src/avatar/fallback-avatar.tsx @@ -54,12 +54,6 @@ const SizesSquare: Record = { fontSize: '8px', textAnchor: 'middle', }, - [Size.Size18]: { - radius: 4, - text: {x: 9, y: 13}, - fontSize: '11px', - textAnchor: 'middle', - }, [Size.Size20]: { radius: 4, text: {x: 2, y: 10}, @@ -92,13 +86,6 @@ const SizesSquare: Record = { letterSpacing: 1, underscore: {x: 5, y: 32, width: 15, height: 2.5}, }, - [Size.Size48]: { - radius: 4, - text: {x: 3, y: 21}, - fontSize: '19px', - letterSpacing: 1, - underscore: {x: 5, y: 32, width: 15, height: 2.5}, - }, [Size.Size56]: { radius: 4, text: {x: 4, y: 28}, @@ -116,13 +103,6 @@ const SizesRound: Record = { textAnchor: 'middle', dominantBaseline: 'middle', }, - [Size.Size18]: { - radius: 4, - fontSize: '11px', - text: {x: '50%', y: '54%'}, - textAnchor: 'middle', - dominantBaseline: 'middle', - }, [Size.Size20]: { radius: 4, fontSize: '9px', @@ -160,14 +140,6 @@ const SizesRound: Record = { textAnchor: 'middle', dominantBaseline: 'middle', }, - [Size.Size48]: { - radius: 4, - fontSize: '19px', - letterSpacing: 1, - text: {x: '50%', y: '54%'}, - textAnchor: 'middle', - dominantBaseline: 'middle', - }, [Size.Size56]: { radius: 4, fontSize: '26px', diff --git a/src/button/button.css b/src/button/button.css index facc1490c1a..b47e184e02b 100644 --- a/src/button/button.css +++ b/src/button/button.css @@ -235,12 +235,12 @@ } .primaryBlock { - --ring-button-default-background-color: var(--ring-button-primary-background-color); + --ring-button-default-background-color: var(--ring-main-color); --ring-button-hover-background-color: var(--ring-main-hover-color); - --ring-button-pressed-background-color: var(--ring-button-primary-background-color); - --ring-button-active-background-color: var(--ring-button-primary-background-color); + --ring-button-pressed-background-color: var(--ring-main-color); + --ring-button-active-background-color: var(--ring-main-color); --ring-button-active-hover-background-color: var(--ring-main-hover-color); - --ring-button-active-pressed-background-color: var(--ring-button-primary-background-color); + --ring-button-active-pressed-background-color: var(--ring-main-color); --ring-button-disabled-background-color: var(--ring-border-hover-color); --ring-button-pressed-border-color: var(--ring-button-primary-border-color); --ring-button-active-border-color: var(--ring-button-primary-border-color); diff --git a/src/button/button.stories.tsx b/src/button/button.stories.tsx index b109e41e035..cccbc1d33ea 100644 --- a/src/button/button.stories.tsx +++ b/src/button/button.stories.tsx @@ -6,8 +6,6 @@ import hourglassIcon from '@jetbrains/icons/hourglass'; import Loader from '../loader/loader'; import LoaderInline from '../loader-inline/loader-inline'; import {ControlsHeight, ControlsHeightContext} from '../global/controls-height'; -import {Col, Grid} from '../grid/grid'; -import Row from '../grid/row'; import Button, {type ButtonProps} from './button'; export default { @@ -26,7 +24,7 @@ single.args = {children: 'Label'}; single.parameters = {screenshots: {skip: true}}; export const basic = () => ( - +
{[ControlsHeight.S, ControlsHeight.M, ControlsHeight.L].map(height => ( {[ @@ -42,11 +40,11 @@ export const basic = () => ( {ghost: true, inline: true}, {danger: true, inline: true}, ].map(typeProps => ( - +
{[{}, {active: true}, {disabled: true}, {loader: true}].map(stateProps => { const icon = height === ControlsHeight.S && !typeProps.inline ? pencil12pxIcon : pencilIcon; return ( - +
{[ {children: 'Button'}, {children: '...', short: true}, @@ -57,24 +55,23 @@ export const basic = () => ( {children: 'Button dropdown', dropdown: true}, {title: 'Just icon button', icon}, ].map(contentProps => ( - +
))} - +
); })} -
- +
))}
))} - +
); basic.storyName = 'basic'; @@ -100,11 +97,24 @@ basic.parameters = { storyStyles: ` `, }; diff --git a/src/button/button.tsx b/src/button/button.tsx index 9e4ebce8d88..cba99a7d7bb 100644 --- a/src/button/button.tsx +++ b/src/button/button.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import classNames from 'classnames'; import chevronDown from '@jetbrains/icons/chevron-down'; import chevron12pxDown from '@jetbrains/icons/chevron-12px-down'; -import deprecate from 'util-deprecate'; import Icon, {type IconProps, type IconType, Size} from '../icon/icon'; import ClickableLink, {type ClickableLinkProps} from '../link/clickable-link'; @@ -24,10 +23,6 @@ export interface ButtonBaseProps { secondary?: boolean | null | undefined; ghost?: boolean | null | undefined; short?: boolean | null | undefined; - /** - * @deprecated Use inline instead - */ - text?: boolean | null | undefined; inline?: boolean | null | undefined; dropdown?: boolean | null | undefined; disabled?: boolean | undefined; @@ -53,11 +48,6 @@ export interface ButtonLinkProps extends ClickableLinkProps, ButtonBaseProps { export type ButtonProps = ButtonButtonProps | ButtonLinkProps; -const warnText = deprecate( - () => {}, - 'Button: "text" prop is deprecated and will be removed in 8.0. Use inline instead.', -); - function removeLinkProps(props: ButtonLinkProps) { const { download, @@ -105,7 +95,6 @@ export class Button extends PureComponent { secondary, ghost, short, - text, dropdown, height, @@ -122,11 +111,7 @@ export class Button extends PureComponent { disabled, ...props } = this.props; - const isInline = inline ?? text ?? !!icon; - - if (text) { - warnText(); - } + const isInline = inline ?? !!icon; const classes = getButtonClasses({ ...this.props, diff --git a/src/collapsible-group/collapsible-group.test.tsx b/src/collapsible-group/collapsible-group.test.tsx index 2b5e466d143..e8cce083968 100644 --- a/src/collapsible-group/collapsible-group.test.tsx +++ b/src/collapsible-group/collapsible-group.test.tsx @@ -151,34 +151,6 @@ describe('', () => { expect(heading.contains(header)).toBe(true); }); - it('should forward every class to the deprecated expand stylesheet', async () => { - // CSS modules are proxied in tests, so compare class tokens in the source files - const {readFile} = await import('node:fs/promises'); - const read = (file: string) => readFile(`${process.cwd()}/src/${file}`, 'utf8'); - // strip comments and quoted strings (import/composes paths), - // then collect .className tokens anywhere in a selector - const classTokens = (css: string) => - new Set( - [ - ...css - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/'[^']*'/g, '') - .matchAll(/\.([a-zA-Z_][\w-]*)/g), - ].map(match => match[1]), - ); - - const deprecatedCss = await read('expand/collapsible-group.css'); - const canonical = classTokens(await read('collapsible-group/collapsible-group.css')); - const deprecated = classTokens(deprecatedCss); - - expect([...deprecated].sort()).toEqual([...canonical].sort()); - - // duplicate selector names are not enough — every alias must actually forward the canonical rule - for (const token of canonical) { - expect(deprecatedCss).toContain(`composes: ${token} from`); - } - }); - it('should support disabling animation', async () => { renderExpand({disableAnimation: true}); diff --git a/src/content-layout/content-layout.css b/src/content-layout/content-layout.css deleted file mode 100644 index 4f7aaa269ad..00000000000 --- a/src/content-layout/content-layout.css +++ /dev/null @@ -1,109 +0,0 @@ -@import '../global/variables.css'; - -@value extra-small-screen-media, small-screen-media from '../global/global.css'; - -.contentLayout { - --ring-content-layout-sidebar-width: calc(var(--ring-unit) * 30); - - position: relative; - - display: flex; - flex-flow: row nowrap; -} - -.contentLayoutContent { - align-self: flex-start; - flex-grow: 2; - - width: 100%; /* without this hack IE11 render contentLayoutContent wider than its container */ - margin: 0 calc(var(--ring-unit) * 4); -} - -.sidebarContainer { - min-width: var(--ring-content-layout-sidebar-width); - max-width: var(--ring-content-layout-sidebar-width); -} - -.sidebarContainerRight { - order: 1; -} - -.sidebar { - overflow: auto; - - box-sizing: border-box; - min-width: var(--ring-content-layout-sidebar-width); - max-width: var(--ring-content-layout-sidebar-width); - height: 100%; - padding-right: calc(var(--ring-unit) * 2); - padding-left: calc(var(--ring-unit) * 4); -} - -.sidebarRight { - padding-right: calc(var(--ring-unit) * 4); - padding-left: calc(var(--ring-unit) * 2); -} - -.sidebarFixedTop { - top: 0; - bottom: 0; - - &.sidebarFixedTop { - position: fixed; - } -} - -.sidebarFixedBottom.sidebarFixedBottom { - position: absolute; - top: auto; - bottom: 0; -} - -.bottomMarker { - position: absolute; - bottom: 0; -} - -.contentLayoutResponsive { - @media extra-small-screen-media, small-screen-media { - & .contentLayoutContent { - margin: 0 16px; - } - - & .sidebar { - position: absolute; - top: 0; - bottom: 0; - left: 0; - - box-sizing: content-box; - padding: 0 16px; - } - - & .sidebarFixedTop { - position: fixed; - } - - & .sidebarFixedBottom { - top: auto; - } - - & .sidebarRight { - right: 0; - left: auto; - } - - & .sidebarContainer { - min-width: 0; - max-width: 0; - } - } - - @media extra-small-screen-media { - & .sidebar { - width: 80%; - min-width: 0; - max-width: none; - } - } -} diff --git a/src/content-layout/content-layout.stories.tsx b/src/content-layout/content-layout.stories.tsx deleted file mode 100644 index baf23d482f7..00000000000 --- a/src/content-layout/content-layout.stories.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import ContentLayout from './content-layout'; -import Sidebar from './sidebar'; - -export default { - title: 'Components/Content Layout', - - parameters: { - notes: 'A component for simple content layout.', - }, -}; - -export const basic = () => ( - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna - aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - -); - -basic.storyName = 'basic'; - -export const withSidebarOnTheLeft = () => ( -
-
-

Some title

-
- - - This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, - consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This is sidebar. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing - elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit - amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - -
- Some content below. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. -
-
- Some content below. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. -
-
- Some content below. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. -
-
-); - -withSidebarOnTheLeft.storyName = 'with sidebar on the left'; - -withSidebarOnTheLeft.parameters = { - storyStyles: ` -`, -}; - -export const withSidebarOnTheRight = () => ( -
-
-

Some title

-
- - - This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, - consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This is sidebar. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit amet, consectetur adipiscing - elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This is sidebar. Lorem ipsum dolor sit - amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - -
- Some content below. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. -
-
- Some content below. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. -
-
- Some content below. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. -
-
-); - -withSidebarOnTheRight.storyName = 'with sidebar on the right'; - -withSidebarOnTheRight.parameters = { - storyStyles: ` - - `, -}; diff --git a/src/content-layout/content-layout.test.tsx b/src/content-layout/content-layout.test.tsx deleted file mode 100644 index d06c2f48104..00000000000 --- a/src/content-layout/content-layout.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {render, screen} from '@testing-library/react'; - -import ContentLayout from './content-layout'; -import Sidebar from './sidebar'; - -import styles from './content-layout.css'; - -describe('Content Layout', () => { - it('should create component', () => { - render(); - expect(screen.getByTestId('content-layout')).to.exist; - }); - - it('should wrap children with div', () => { - render(); - expect(screen.getByTestId('content-layout')).to.have.tagName('div'); - }); - - it('should use passed className', () => { - render(); - expect(screen.getByTestId('content-layout')).to.have.class('test-class'); - }); - - it('should render sidebar', () => { - render( - - {'In sidebar'} -
{'Foo'}
-
, - ); - - expect(screen.getByRole('complementary')).to.exist; - }); - - it('should render sidebar on the right', () => { - render( - - {'In sidebar'} -
{'Foo'}
-
, - ); - - expect(screen.getByRole('complementary')).to.have.descendants(`div.${styles.sidebarRight}`); - }); -}); diff --git a/src/content-layout/content-layout.tsx b/src/content-layout/content-layout.tsx deleted file mode 100644 index 7d5bab4673f..00000000000 --- a/src/content-layout/content-layout.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import {Children, cloneElement, Component, type HTMLAttributes, type ReactElement} from 'react'; -import classNames from 'classnames'; - -import Sidebar, {type SidebarProps} from './sidebar'; - -import styles from './content-layout.css'; - -export interface ContentLayoutProps extends HTMLAttributes { - responsive: boolean; - contentClassName?: string | null | undefined; -} - -/** - * @name Content Layout - * @deprecated Will be removed in Ring UI 8.0. - */ - -export default class ContentLayout extends Component { - static defaultProps = { - responsive: true, - }; - - state = { - contentNode: null, - }; - - saveContentNode = (contentNode: HTMLElement | null) => { - this.setState({contentNode}); - }; - - render() { - const {children, className, contentClassName, responsive, ...restProps} = this.props; - - const classes = classNames(styles.contentLayout, className, { - [styles.contentLayoutResponsive]: responsive, - }); - - const contentClasses = classNames(styles.contentLayoutContent, contentClassName); - - const childrenArray = Children.toArray(children); - const sidebarChild = childrenArray.filter( - (child): child is ReactElement => - !!child && typeof child === 'object' && 'type' in child && child.type === Sidebar, - )[0]; - - const sidebar = - sidebarChild && - cloneElement(sidebarChild, { - contentNode: this.state.contentNode, - }); - const contentChildren = childrenArray.filter(child => child !== sidebarChild); - - return ( -
- {sidebar} -
- {contentChildren} -
-
- ); - } -} - -export {default as Sidebar} from './sidebar'; diff --git a/src/content-layout/sidebar.tsx b/src/content-layout/sidebar.tsx deleted file mode 100644 index b947f37dbf3..00000000000 --- a/src/content-layout/sidebar.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import {Component, type HTMLAttributes} from 'react'; -import classNames from 'classnames'; -import {Waypoint} from 'react-waypoint'; - -import styles from './content-layout.css'; - -const ABOVE = 'above'; -const INSIDE = 'inside'; - -export interface SidebarProps extends HTMLAttributes { - right?: boolean | null | undefined; - containerClassName?: string | null | undefined; - fixedClassName?: string | null | undefined; - contentNode?: HTMLElement | null | undefined; -} - -/** - * @name Sidebar - * @deprecated Will be removed in Ring UI 8.0. - */ -export default class Sidebar extends Component { - state = { - topIsOutside: true, - bottomIsOutside: true, - sidebarVisibleHeight: undefined, - }; - - sidebarNode?: HTMLElement | null; - - handleTopWaypoint = ({currentPosition}: Waypoint.CallbackArgs) => { - this.setState({topIsOutside: currentPosition === ABOVE}); - }; - - handleBottomWaypoint = ({currentPosition, waypointTop}: Waypoint.CallbackArgs) => { - this.setState({ - sidebarVisibleHeight: waypointTop, - bottomIsOutside: currentPosition !== INSIDE, - }); - }; - - shouldUseFixation() { - const {contentNode} = this.props; - const {sidebarNode} = this; - if (!contentNode || !sidebarNode) { - return false; - } - return contentNode.offsetHeight >= sidebarNode.offsetHeight; - } - - shouldFixateBottom() { - const {topIsOutside, bottomIsOutside} = this.state; - return !bottomIsOutside && topIsOutside && this.shouldUseFixation(); - } - - sidebarRef = (node: HTMLElement | null) => { - this.sidebarNode = node; - }; - - render() { - const {right, children, className, containerClassName, fixedClassName, contentNode, ...restProps} = this.props; - const {topIsOutside, bottomIsOutside, sidebarVisibleHeight} = this.state; - - const shouldFixateTop = bottomIsOutside && topIsOutside && this.shouldUseFixation(); - const shouldFixateBottom = this.shouldFixateBottom(); - - const containerClasses = classNames(styles.sidebarContainer, containerClassName, { - [styles.sidebarContainerRight]: right, - }); - - const classes = classNames(styles.sidebar, className, { - [styles.sidebarRight]: right, - [styles.sidebarFixedTop]: shouldFixateTop, - [styles.sidebarFixedBottom]: shouldFixateBottom, - [fixedClassName ?? '']: shouldFixateTop || shouldFixateBottom, - }); - - const style = { - maxHeight: shouldFixateBottom && sidebarVisibleHeight ? `${sidebarVisibleHeight}px` : undefined, - }; - - return ( - - ); - } -} diff --git a/src/expand/collapsible-group.css b/src/expand/collapsible-group.css deleted file mode 100644 index 00b1afbbcef..00000000000 --- a/src/expand/collapsible-group.css +++ /dev/null @@ -1,76 +0,0 @@ -/* Deprecated alias of `../collapsible-group/collapsible-group.css` (removed in Ring UI 8.0). - Class names are forwarded via `composes` so the canonical stylesheet stays the single source of - the actual rules, while `import styles from '.../components/expand/collapsible-group.css'` keeps - returning the full token map. */ - -.expand { - composes: expand from '../collapsible-group/collapsible-group.css'; -} - -.collapseRoot { - composes: collapseRoot from '../collapsible-group/collapsible-group.css'; -} - -.hovered { - composes: hovered from '../collapsible-group/collapsible-group.css'; -} - -.expanded { - composes: expanded from '../collapsible-group/collapsible-group.css'; -} - -.focused { - composes: focused from '../collapsible-group/collapsible-group.css'; -} - -.header { - composes: header from '../collapsible-group/collapsible-group.css'; -} - -.heading { - composes: heading from '../collapsible-group/collapsible-group.css'; -} - -.headerButton { - composes: headerButton from '../collapsible-group/collapsible-group.css'; -} - -.headerStatic { - composes: headerStatic from '../collapsible-group/collapsible-group.css'; -} - -.headerContent { - composes: headerContent from '../collapsible-group/collapsible-group.css'; -} - -.avatarGroup { - composes: avatarGroup from '../collapsible-group/collapsible-group.css'; -} - -.title { - composes: title from '../collapsible-group/collapsible-group.css'; -} - -.subtitleGroup { - composes: subtitleGroup from '../collapsible-group/collapsible-group.css'; -} - -.subtitle { - composes: subtitle from '../collapsible-group/collapsible-group.css'; -} - -.subtitleChevron { - composes: subtitleChevron from '../collapsible-group/collapsible-group.css'; -} - -.toggle { - composes: toggle from '../collapsible-group/collapsible-group.css'; -} - -.toggleIcon { - composes: toggleIcon from '../collapsible-group/collapsible-group.css'; -} - -.body { - composes: body from '../collapsible-group/collapsible-group.css'; -} diff --git a/src/expand/collapsible-group.test.tsx b/src/expand/collapsible-group.test.tsx deleted file mode 100644 index ce2f3798c81..00000000000 --- a/src/expand/collapsible-group.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {createRef} from 'react'; -import {render, screen} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import CollapsibleGroup from './collapsible-group'; - -// Compatibility smoke test for the deprecated `expand` import path. -// Full behavior is covered by `../collapsible-group/collapsible-group.test.tsx`. -describe(' (deprecated expand path)', () => { - it('forwards ref and props through to the renamed component', () => { - const ref = createRef(); - render( - - {'Body content'} - , - ); - - expect(ref.current).toBe(document.querySelector('[data-test="deprecated-expand"]')); - expect(screen.getByRole('button', {name: 'Title'})).not.toBeNull(); - }); - - it('toggles on click', async () => { - render({'Body content'}); - - const header = screen.getByRole('button', {name: 'Title'}); - expect(header.getAttribute('aria-expanded')).toBe('false'); - expect(screen.queryByText('Body content')).toBeNull(); - - await userEvent.click(header); - - expect(header.getAttribute('aria-expanded')).toBe('true'); - expect(screen.getByText('Body content')).not.toBeNull(); - }); -}); diff --git a/src/expand/collapsible-group.tsx b/src/expand/collapsible-group.tsx deleted file mode 100644 index 9deda0d899d..00000000000 --- a/src/expand/collapsible-group.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {forwardRef} from 'react'; -import deprecate from 'util-deprecate'; - -import CollapsibleGroup, {type CollapsibleGroupProps} from '../collapsible-group/collapsible-group'; - -const warnDeprecation = deprecate( - () => {}, - '`CollapsibleGroup` from `@jetbrains/ring-ui/components/expand/collapsible-group` is deprecated and will be removed in Ring UI 8.0. Import it from `@jetbrains/ring-ui/components/collapsible-group/collapsible-group` instead.', -); - -/** - * @deprecated The `expand` module has been renamed to `collapsible-group`. This re-export will be removed in - * Ring UI 8.0. Use `CollapsibleGroup` from `@jetbrains/ring-ui/components/collapsible-group/collapsible-group` instead. - */ -const DeprecatedCollapsibleGroup = forwardRef((props, ref) => { - warnDeprecation(); - return ; -}); - -DeprecatedCollapsibleGroup.displayName = 'CollapsibleGroup'; - -export type {CollapsibleGroupProps}; -export default DeprecatedCollapsibleGroup; diff --git a/src/global/variables.css b/src/global/variables.css index 07c27662af5..debabc51f0a 100644 --- a/src/global/variables.css +++ b/src/global/variables.css @@ -20,8 +20,6 @@ --ring-border-disabled-color: rgb(var(--ring-border-disabled-components)); /* #ebecf0 */ --ring-border-selected-disabled-components: 201, 204, 214; --ring-border-selected-disabled-color: rgb(var(--ring-border-selected-disabled-components)); /* #c9ccd6 */ - --ring-border-disabled-active-components: var(--ring-border-hover-components); - --ring-border-disabled-active-color: var(--ring-border-hover-color); /* TODO: remove in 8.0 in favor of --ring-border-hover-color */ --ring-icon-disabled-components: 168, 173, 189; --ring-icon-disabled-color: rgb(var(--ring-icon-disabled-components)); /* #A8ADBD */ --ring-border-hover-components: 160, 189, 248; @@ -36,8 +34,6 @@ --ring-icon-hover-color: rgb(var(--ring-icon-hover-components)); /* #5a5d6b */ --ring-main-components: 51, 105, 214; --ring-main-color: rgb(var(--ring-main-components)); /* #3369D6 */ - --ring-action-link-components: var(--ring-link-components); - --ring-action-link-color: var(--ring-link-color); /* #315FBD */ /* TODO: remove in 8.0 in favor of --ring-link-color */ --ring-main-hover-components: 49, 95, 189; --ring-main-hover-color: rgb(var(--ring-main-hover-components)); /* #315FBD */ --ring-main-success-components: 31, 128, 57; @@ -94,8 +90,6 @@ --ring-popup-shadow-color: rgba(var(--ring-popup-border-components), 0.1); --ring-popup-secondary-shadow-color: rgba(var(--ring-popup-border-components), 0.04); --ring-message-shadow-color: rgba(var(--ring-popup-border-components), 0.3); - --ring-pinned-shadow-components: 108, 112, 126; - --ring-pinned-shadow-color: rgb(var(--ring-pinned-shadow-components)); /* #6C707E */ /* TODO remove in 8.0 */ --ring-button-danger-hover-components: 219, 59, 75; --ring-button-danger-hover-color: rgb(var(--ring-button-danger-hover-components)); /* #DB3B4B */ --ring-button-primary-border-components: 46, 85, 163; @@ -108,8 +102,6 @@ /* Text */ --ring-search-components: 112, 156, 245; --ring-search-color: rgb(var(--ring-search-components)); /* #709CF5 */ - --ring-hint-components: 46, 85, 163; - --ring-hint-color: rgb(var(--ring-hint-components)); /* #2E55A3 */ /* TODO: remove in 8.0 */ --ring-link-components: 49, 95, 189; --ring-link-color: rgb(var(--ring-link-components)); /* #315FBD */ --ring-link-hover-components: 46, 85, 163; @@ -167,8 +159,6 @@ --ring-disabled-selected-background-components: 235, 236, 240; --ring-disabled-selected-background-color: rgb(var(--ring-disabled-selected-background-components)); /* #EBECF0 */ --ring-button-danger-active-color: var(--ring-error-container-light-color); /* #FAD4D8 */ - --ring-button-loader-background: rgba(var(--ring-white-text-components), 0.4); /* TODO remove in 8.0 */ - --ring-button-primary-background-color: var(--ring-main-color); /* TODO remove in 8.0 */ --ring-table-loader-background-color: rgba(var(--ring-content-background-components), 0.5); /* #FFFFFF50 */ --ring-removed-subtle-background-components: 255, 235, 236; --ring-removed-subtle-background-color: rgb(var(--ring-removed-subtle-background-components)); /* #FFEBEC */ diff --git a/src/global/variables.interface.ts b/src/global/variables.interface.ts index 59d54485938..dd2371ea889 100644 --- a/src/global/variables.interface.ts +++ b/src/global/variables.interface.ts @@ -22,13 +22,11 @@ export interface RingCSSProperties { '--ring-popup-border-color'?: Property.BorderColor; '--ring-popup-shadow-color'?: Property.Color; '--ring-message-shadow-color'?: Property.Color; - '--ring-pinned-shadow-color'?: Property.Color; '--ring-button-danger-hover-color'?: Property.Color; '--ring-button-primary-border-color'?: Property.Color; /* Text */ '--ring-search-color'?: Property.Color; - '--ring-hint-color'?: Property.Color; '--ring-link-color'?: Property.Color; '--ring-link-hover-color'?: Property.Color; '--ring-error-color'?: Property.Color; @@ -56,8 +54,6 @@ export interface RingCSSProperties { '--ring-disabled-background-color'?: Property.BackgroundColor; '--ring-disabled-selected-background-color'?: Property.BackgroundColor; '--ring-button-danger-active-color'?: Property.BackgroundColor; - '--ring-button-loader-background'?: Property.BackgroundColor; - '--ring-button-primary-background-color'?: Property.BackgroundColor; /* Code */ '--ring-code-background-color'?: Property.BackgroundColor; diff --git a/src/global/variables.stories.tsx b/src/global/variables.stories.tsx index a40390bac2a..19b9e1b6871 100644 --- a/src/global/variables.stories.tsx +++ b/src/global/variables.stories.tsx @@ -116,7 +116,6 @@ const renderColors = () => ( -
@@ -124,7 +123,6 @@ const renderColors = () => (

Text colors:

- @@ -158,7 +156,6 @@ const renderColors = () => ( - diff --git a/src/global/variables_dark.css b/src/global/variables_dark.css index 0f3710b6cf3..8d2585f1406 100644 --- a/src/global/variables_dark.css +++ b/src/global/variables_dark.css @@ -15,8 +15,6 @@ --ring-border-disabled-color: rgb(var(--ring-border-disabled-components)); /* #4E5157 */ --ring-border-selected-disabled-components: 90, 93, 99; --ring-border-selected-disabled-color: rgb(var(--ring-border-selected-disabled-components)); /* #5A5D63 */ - --ring-border-disabled-active-components: var(--ring-border-hover-components); - --ring-border-disabled-active-color: var(--ring-border-hover-color); /* TODO: remove in 8.0 in favor of --ring-border-hover-color */ --ring-icon-disabled-components: 111, 115, 122; --ring-icon-disabled-color: rgb(var(--ring-icon-disabled-components)); /* #6F737A */ --ring-border-hover-components: 55, 95, 173; @@ -82,8 +80,6 @@ --ring-popup-shadow-color: rgba(0, 0, 0, 0.31); --ring-popup-secondary-shadow-color: rgba(0, 0, 0, 0.37); --ring-message-shadow-color: rgba(var(--ring-popup-border-components), 0.3); - --ring-pinned-shadow-components: 0, 0, 0; - --ring-pinned-shadow-color: rgb(var(--ring-pinned-shadow-components)); /* #000 */ /* TODO remove in 8.0 */ --ring-button-danger-hover-color: var(--ring-error-color); --ring-button-primary-border-components: 153, 187, 255; --ring-button-primary-border-color: rgb(var(--ring-button-primary-border-components)); /* #99BBFF */ @@ -93,8 +89,6 @@ --ring-dialog-shadow: 0 4px 16px var(--ring-popup-shadow-color), 0 2px 6px var(--ring-popup-secondary-shadow-color); /* Text */ - --ring-hint-components: 134, 138, 145; - --ring-hint-color: rgb(var(--ring-hint-components)); /* #868A91 */ /* TODO: remove in 8.0 */ --ring-link-components: 153, 187, 255; --ring-link-color: rgb(var(--ring-link-components)); /* #99BBFF */ --ring-link-hover-components: 107, 155, 250; @@ -153,7 +147,6 @@ --ring-disabled-selected-background-components: 67, 69, 74; --ring-disabled-selected-background-color: rgb(var(--ring-disabled-selected-background-components)); /* #43454A */ --ring-button-danger-active-color: var(--ring-error-container-light-color); /* #5E3838 */ - --ring-button-primary-background-color: var(--ring-main-color); /* TODO remove in 8.0 */ --ring-table-loader-background-color: rgba(var(--ring-content-background-components), 0.5); /* #2B2D3050 */ --ring-removed-subtle-background-components: 64, 41, 41; --ring-removed-subtle-background-color: rgb(var(--ring-removed-subtle-background-components)); /* #402929 */ diff --git a/src/grid/col.tsx b/src/grid/col.tsx deleted file mode 100644 index 724cb2c737a..00000000000 --- a/src/grid/col.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import {Component, type HTMLAttributes} from 'react'; -import classNames from 'classnames'; - -import dataTests from '../global/data-tests'; - -import styles from './grid.css'; - -const classMap: Record = { - xs: 'col-xs', - sm: 'col-sm', - md: 'col-md', - lg: 'col-lg', - xsOffset: 'col-xs-offset', - smOffset: 'col-sm-offset', - mdOffset: 'col-md-offset', - lgOffset: 'col-lg-offset', -}; - -export interface ColProps extends HTMLAttributes { - 'data-test'?: string | null | undefined; - xs?: boolean | number | null | undefined; - sm?: boolean | number | null | undefined; - md?: boolean | number | null | undefined; - lg?: boolean | number | null | undefined; - xsOffset?: number | null | undefined; - smOffset?: number | null | undefined; - mdOffset?: number | null | undefined; - lgOffset?: number | null | undefined; - reverse?: boolean | null | undefined; -} - -/** - * Converts props like "xs=9 xsOffset={2}" to classes "col-xs-9 col-xs-offset-2" - * @param {Object} props incoming props - * @mockReturnValue {Array} result classnames - */ -function getClassNames(props: Omit) { - return Object.entries(props) - .filter(([key, value]) => classMap[key] && value != null) - .map( - ([key, value]) => - (styles as Record)[ - Number.isInteger(value) ? `${classMap[key]}-${value}` : classMap[key] - ], - ); -} - -export default class Col extends Component { - render() { - const { - children, - className, - 'data-test': dataTest, - reverse, - xs, - sm, - md, - lg, - xsOffset, - smOffset, - mdOffset, - lgOffset, - ...restProps - } = this.props; - const classes = classNames( - styles.col, - className, - getClassNames({xs, sm, md, lg, xsOffset, smOffset, mdOffset, lgOffset}), - { - [styles.reverse]: reverse, - }, - ); - - return ( -
- {children} -
- ); - } -} diff --git a/src/grid/grid.css b/src/grid/grid.css deleted file mode 100644 index 6b3e698a9d5..00000000000 --- a/src/grid/grid.css +++ /dev/null @@ -1,920 +0,0 @@ -@import '../global/variables.css'; - -@value breakpoint-small, breakpoint-middle, breakpoint-large from '../global/global.css'; -@value large-screen-media, middle-screen-media, small-screen-media from '../global/global.css'; - -.container-fluid, -.container, -.row { - --ring-grid-gutter-width: calc(var(--ring-unit) * 2); - --ring-grid-gutter-compensation: calc(var(--ring-grid-gutter-width) / -2); - --ring-grid-outer-margin: calc(var(--ring-unit) * 2); - --ring-grid-container-small: calc(breakpoint-small + var(--ring-grid-gutter-width)); - --ring-grid-container-medium: calc(breakpoint-middle + var(--ring-grid-gutter-width)); - --ring-grid-container-large: calc(breakpoint-large + var(--ring-grid-gutter-width)); - --ring-grid-width-1: 8.3333%; - --ring-grid-width-2: 16.6667%; - --ring-grid-width-3: 25%; - --ring-grid-width-4: 33.3333%; - --ring-grid-width-5: 41.6667%; - --ring-grid-width-6: 50%; - --ring-grid-width-7: 58.3333%; - --ring-grid-width-8: 66.6667%; - --ring-grid-width-9: 75%; - --ring-grid-width-10: 83.3333%; - --ring-grid-width-11: 91.6667%; - --ring-grid-width-12: 100%; -} - -.container-fluid, -.container { - margin-right: auto; - margin-left: auto; -} - -.container-fluid { - min-width: calc(var(--ring-unit) * 40); - padding-right: var(--ring-grid-outer-margin); - padding-left: var(--ring-grid-outer-margin); -} - -.row { - display: flex; - flex: 0 1 auto; - flex-flow: row wrap; - - box-sizing: border-box; - margin-right: var(--ring-grid-gutter-compensation); - margin-left: var(--ring-grid-gutter-compensation); -} - -.row.reverse { - flex-direction: row-reverse; -} - -.col { - margin-top: var(--ring-unit); - margin-bottom: var(--ring-unit); -} - -.col.reverse { - flex-direction: column-reverse; -} - -.col-xs, -.col-xs-1, -.col-xs-2, -.col-xs-3, -.col-xs-4, -.col-xs-5, -.col-xs-6, -.col-xs-7, -.col-xs-8, -.col-xs-9, -.col-xs-10, -.col-xs-11, -.col-xs-12, -.col-xs-offset-0, -.col-xs-offset-1, -.col-xs-offset-2, -.col-xs-offset-3, -.col-xs-offset-4, -.col-xs-offset-5, -.col-xs-offset-6, -.col-xs-offset-7, -.col-xs-offset-8, -.col-xs-offset-9, -.col-xs-offset-10, -.col-xs-offset-11, -.col-xs-offset-12 { - flex: 0 0 auto; - - box-sizing: border-box; - padding-right: calc(var(--ring-grid-gutter-width) / 2); - padding-left: calc(var(--ring-grid-gutter-width) / 2); -} - -.col-xs { - flex-basis: 0; - flex-grow: 1; - - max-width: 100%; -} - -.col-xs-1 { - flex-basis: var(--ring-grid-width-1); - - max-width: var(--ring-grid-width-1); -} - -.col-xs-2 { - flex-basis: var(--ring-grid-width-2); - - max-width: var(--ring-grid-width-2); -} - -.col-xs-3 { - flex-basis: var(--ring-grid-width-3); - - max-width: var(--ring-grid-width-3); -} - -.col-xs-4 { - flex-basis: var(--ring-grid-width-4); - - max-width: var(--ring-grid-width-4); -} - -.col-xs-5 { - flex-basis: var(--ring-grid-width-5); - - max-width: var(--ring-grid-width-5); -} - -.col-xs-6 { - flex-basis: var(--ring-grid-width-6); - - max-width: var(--ring-grid-width-6); -} - -.col-xs-7 { - flex-basis: var(--ring-grid-width-7); - - max-width: var(--ring-grid-width-7); -} - -.col-xs-8 { - flex-basis: var(--ring-grid-width-8); - - max-width: var(--ring-grid-width-8); -} - -.col-xs-9 { - flex-basis: var(--ring-grid-width-9); - - max-width: var(--ring-grid-width-9); -} - -.col-xs-10 { - flex-basis: var(--ring-grid-width-10); - - max-width: var(--ring-grid-width-10); -} - -.col-xs-11 { - flex-basis: var(--ring-grid-width-11); - - max-width: var(--ring-grid-width-11); -} - -.col-xs-12 { - flex-basis: var(--ring-grid-width-12); - - max-width: var(--ring-grid-width-12); -} - -.col-xs-offset-0 { - margin-left: 0; -} - -.col-xs-offset-1 { - margin-left: var(--ring-grid-width-1); -} - -.col-xs-offset-2 { - margin-left: var(--ring-grid-width-2); -} - -.col-xs-offset-3 { - margin-left: var(--ring-grid-width-3); -} - -.col-xs-offset-4 { - margin-left: var(--ring-grid-width-4); -} - -.col-xs-offset-5 { - margin-left: var(--ring-grid-width-5); -} - -.col-xs-offset-6 { - margin-left: var(--ring-grid-width-6); -} - -.col-xs-offset-7 { - margin-left: var(--ring-grid-width-7); -} - -.col-xs-offset-8 { - margin-left: var(--ring-grid-width-8); -} - -.col-xs-offset-9 { - margin-left: var(--ring-grid-width-9); -} - -.col-xs-offset-10 { - margin-left: var(--ring-grid-width-10); -} - -.col-xs-offset-11 { - margin-left: var(--ring-grid-width-11); -} - -.start-xs { - justify-content: flex-start; - - text-align: start; -} - -.center-xs { - justify-content: center; - - text-align: center; -} - -.end-xs { - justify-content: flex-end; - - text-align: end; -} - -.top-xs { - align-items: flex-start; -} - -.middle-xs { - align-items: center; -} - -.baseline-xs { - align-items: baseline; -} - -.bottom-xs { - align-items: flex-end; -} - -.around-xs { - justify-content: space-around; -} - -.between-xs { - justify-content: space-between; -} - -.first-xs { - order: -1; -} - -.last-xs { - order: 1; -} - -@media small-screen-media { - .container { - width: var(--ring-grid-container-small); - } - - .col-sm, - .col-sm-1, - .col-sm-2, - .col-sm-3, - .col-sm-4, - .col-sm-5, - .col-sm-6, - .col-sm-7, - .col-sm-8, - .col-sm-9, - .col-sm-10, - .col-sm-11, - .col-sm-12, - .col-sm-offset-0, - .col-sm-offset-1, - .col-sm-offset-2, - .col-sm-offset-3, - .col-sm-offset-4, - .col-sm-offset-5, - .col-sm-offset-6, - .col-sm-offset-7, - .col-sm-offset-8, - .col-sm-offset-9, - .col-sm-offset-10, - .col-sm-offset-11, - .col-sm-offset-12 { - flex: 0 0 auto; - - box-sizing: border-box; - padding-right: calc(var(--ring-grid-gutter-width) / 2); - padding-left: calc(var(--ring-grid-gutter-width) / 2); - } - - .col-sm { - flex-basis: 0; - flex-grow: 1; - - max-width: 100%; - } - - .col-sm-1 { - flex-basis: var(--ring-grid-width-1); - - max-width: var(--ring-grid-width-1); - } - - .col-sm-2 { - flex-basis: var(--ring-grid-width-2); - - max-width: var(--ring-grid-width-2); - } - - .col-sm-3 { - flex-basis: var(--ring-grid-width-3); - - max-width: var(--ring-grid-width-3); - } - - .col-sm-4 { - flex-basis: var(--ring-grid-width-4); - - max-width: var(--ring-grid-width-4); - } - - .col-sm-5 { - flex-basis: var(--ring-grid-width-5); - - max-width: var(--ring-grid-width-5); - } - - .col-sm-6 { - flex-basis: var(--ring-grid-width-6); - - max-width: var(--ring-grid-width-6); - } - - .col-sm-7 { - flex-basis: var(--ring-grid-width-7); - - max-width: var(--ring-grid-width-7); - } - - .col-sm-8 { - flex-basis: var(--ring-grid-width-8); - - max-width: var(--ring-grid-width-8); - } - - .col-sm-9 { - flex-basis: var(--ring-grid-width-9); - - max-width: var(--ring-grid-width-9); - } - - .col-sm-10 { - flex-basis: var(--ring-grid-width-10); - - max-width: var(--ring-grid-width-10); - } - - .col-sm-11 { - flex-basis: var(--ring-grid-width-11); - - max-width: var(--ring-grid-width-11); - } - - .col-sm-12 { - flex-basis: var(--ring-grid-width-12); - - max-width: var(--ring-grid-width-12); - } - - .col-sm-offset-0 { - margin-left: 0; - } - - .col-sm-offset-1 { - margin-left: var(--ring-grid-width-1); - } - - .col-sm-offset-2 { - margin-left: var(--ring-grid-width-2); - } - - .col-sm-offset-3 { - margin-left: var(--ring-grid-width-3); - } - - .col-sm-offset-4 { - margin-left: var(--ring-grid-width-4); - } - - .col-sm-offset-5 { - margin-left: var(--ring-grid-width-5); - } - - .col-sm-offset-6 { - margin-left: var(--ring-grid-width-6); - } - - .col-sm-offset-7 { - margin-left: var(--ring-grid-width-7); - } - - .col-sm-offset-8 { - margin-left: var(--ring-grid-width-8); - } - - .col-sm-offset-9 { - margin-left: var(--ring-grid-width-9); - } - - .col-sm-offset-10 { - margin-left: var(--ring-grid-width-10); - } - - .col-sm-offset-11 { - margin-left: var(--ring-grid-width-11); - } - - .start-sm { - justify-content: flex-start; - - text-align: start; - } - - .center-sm { - justify-content: center; - - text-align: center; - } - - .end-sm { - justify-content: flex-end; - - text-align: end; - } - - .top-sm { - align-items: flex-start; - } - - .middle-sm { - align-items: center; - } - - .baseline-sm { - align-items: baseline; - } - - .bottom-sm { - align-items: flex-end; - } - - .around-sm { - justify-content: space-around; - } - - .between-sm { - justify-content: space-between; - } - - .first-sm { - order: -1; - } - - .last-sm { - order: 1; - } -} - -@media middle-screen-media { - .container { - width: var(--ring-grid-container-medium); - } - - .col-md, - .col-md-1, - .col-md-2, - .col-md-3, - .col-md-4, - .col-md-5, - .col-md-6, - .col-md-7, - .col-md-8, - .col-md-9, - .col-md-10, - .col-md-11, - .col-md-12, - .col-md-offset-0, - .col-md-offset-1, - .col-md-offset-2, - .col-md-offset-3, - .col-md-offset-4, - .col-md-offset-5, - .col-md-offset-6, - .col-md-offset-7, - .col-md-offset-8, - .col-md-offset-9, - .col-md-offset-10, - .col-md-offset-11, - .col-md-offset-12 { - flex: 0 0 auto; - - box-sizing: border-box; - padding-right: calc(var(--ring-grid-gutter-width) / 2); - padding-left: calc(var(--ring-grid-gutter-width) / 2); - } - - .col-md { - flex-basis: 0; - flex-grow: 1; - - max-width: 100%; - } - - .col-md-1 { - flex-basis: var(--ring-grid-width-1); - - max-width: var(--ring-grid-width-1); - } - - .col-md-2 { - flex-basis: var(--ring-grid-width-2); - - max-width: var(--ring-grid-width-2); - } - - .col-md-3 { - flex-basis: var(--ring-grid-width-3); - - max-width: var(--ring-grid-width-3); - } - - .col-md-4 { - flex-basis: var(--ring-grid-width-4); - - max-width: var(--ring-grid-width-4); - } - - .col-md-5 { - flex-basis: var(--ring-grid-width-5); - - max-width: var(--ring-grid-width-5); - } - - .col-md-6 { - flex-basis: var(--ring-grid-width-6); - - max-width: var(--ring-grid-width-6); - } - - .col-md-7 { - flex-basis: var(--ring-grid-width-7); - - max-width: var(--ring-grid-width-7); - } - - .col-md-8 { - flex-basis: var(--ring-grid-width-8); - - max-width: var(--ring-grid-width-8); - } - - .col-md-9 { - flex-basis: var(--ring-grid-width-9); - - max-width: var(--ring-grid-width-9); - } - - .col-md-10 { - flex-basis: var(--ring-grid-width-10); - - max-width: var(--ring-grid-width-10); - } - - .col-md-11 { - flex-basis: var(--ring-grid-width-11); - - max-width: var(--ring-grid-width-11); - } - - .col-md-12 { - flex-basis: var(--ring-grid-width-12); - - max-width: var(--ring-grid-width-12); - } - - .col-md-offset-0 { - margin-left: 0; - } - - .col-md-offset-1 { - margin-left: var(--ring-grid-width-1); - } - - .col-md-offset-2 { - margin-left: var(--ring-grid-width-2); - } - - .col-md-offset-3 { - margin-left: var(--ring-grid-width-3); - } - - .col-md-offset-4 { - margin-left: var(--ring-grid-width-4); - } - - .col-md-offset-5 { - margin-left: var(--ring-grid-width-5); - } - - .col-md-offset-6 { - margin-left: var(--ring-grid-width-6); - } - - .col-md-offset-7 { - margin-left: var(--ring-grid-width-7); - } - - .col-md-offset-8 { - margin-left: var(--ring-grid-width-8); - } - - .col-md-offset-9 { - margin-left: var(--ring-grid-width-9); - } - - .col-md-offset-10 { - margin-left: var(--ring-grid-width-10); - } - - .col-md-offset-11 { - margin-left: var(--ring-grid-width-11); - } - - .start-md { - justify-content: flex-start; - - text-align: start; - } - - .center-md { - justify-content: center; - - text-align: center; - } - - .end-md { - justify-content: flex-end; - - text-align: end; - } - - .top-md { - align-items: flex-start; - } - - .middle-md { - align-items: center; - } - - .baseline-md { - align-items: baseline; - } - - .bottom-md { - align-items: flex-end; - } - - .around-md { - justify-content: space-around; - } - - .between-md { - justify-content: space-between; - } - - .first-md { - order: -1; - } - - .last-md { - order: 1; - } -} - -@media large-screen-media { - .container { - width: var(--ring-grid-container-large); - } - - .col-lg, - .col-lg-1, - .col-lg-2, - .col-lg-3, - .col-lg-4, - .col-lg-5, - .col-lg-6, - .col-lg-7, - .col-lg-8, - .col-lg-9, - .col-lg-10, - .col-lg-11, - .col-lg-12, - .col-lg-offset-0, - .col-lg-offset-1, - .col-lg-offset-2, - .col-lg-offset-3, - .col-lg-offset-4, - .col-lg-offset-5, - .col-lg-offset-6, - .col-lg-offset-7, - .col-lg-offset-8, - .col-lg-offset-9, - .col-lg-offset-10, - .col-lg-offset-11, - .col-lg-offset-12 { - flex: 0 0 auto; - - box-sizing: border-box; - padding-right: calc(var(--ring-grid-gutter-width) / 2); - padding-left: calc(var(--ring-grid-gutter-width) / 2); - } - - .col-lg { - flex-basis: 0; - flex-grow: 1; - - max-width: 100%; - } - - .col-lg-1 { - flex-basis: var(--ring-grid-width-1); - - max-width: var(--ring-grid-width-1); - } - - .col-lg-2 { - flex-basis: var(--ring-grid-width-2); - - max-width: var(--ring-grid-width-2); - } - - .col-lg-3 { - flex-basis: var(--ring-grid-width-3); - - max-width: var(--ring-grid-width-3); - } - - .col-lg-4 { - flex-basis: var(--ring-grid-width-4); - - max-width: var(--ring-grid-width-4); - } - - .col-lg-5 { - flex-basis: var(--ring-grid-width-5); - - max-width: var(--ring-grid-width-5); - } - - .col-lg-6 { - flex-basis: var(--ring-grid-width-6); - - max-width: var(--ring-grid-width-6); - } - - .col-lg-7 { - flex-basis: var(--ring-grid-width-7); - - max-width: var(--ring-grid-width-7); - } - - .col-lg-8 { - flex-basis: var(--ring-grid-width-8); - - max-width: var(--ring-grid-width-8); - } - - .col-lg-9 { - flex-basis: var(--ring-grid-width-9); - - max-width: var(--ring-grid-width-9); - } - - .col-lg-10 { - flex-basis: var(--ring-grid-width-10); - - max-width: var(--ring-grid-width-10); - } - - .col-lg-11 { - flex-basis: var(--ring-grid-width-11); - - max-width: var(--ring-grid-width-11); - } - - .col-lg-12 { - flex-basis: var(--ring-grid-width-12); - - max-width: var(--ring-grid-width-12); - } - - .col-lg-offset-0 { - margin-left: 0; - } - - .col-lg-offset-1 { - margin-left: var(--ring-grid-width-1); - } - - .col-lg-offset-2 { - margin-left: var(--ring-grid-width-2); - } - - .col-lg-offset-3 { - margin-left: var(--ring-grid-width-3); - } - - .col-lg-offset-4 { - margin-left: var(--ring-grid-width-4); - } - - .col-lg-offset-5 { - margin-left: var(--ring-grid-width-5); - } - - .col-lg-offset-6 { - margin-left: var(--ring-grid-width-6); - } - - .col-lg-offset-7 { - margin-left: var(--ring-grid-width-7); - } - - .col-lg-offset-8 { - margin-left: var(--ring-grid-width-8); - } - - .col-lg-offset-9 { - margin-left: var(--ring-grid-width-9); - } - - .col-lg-offset-10 { - margin-left: var(--ring-grid-width-10); - } - - .col-lg-offset-11 { - margin-left: var(--ring-grid-width-11); - } - - .start-lg { - justify-content: flex-start; - - text-align: start; - } - - .center-lg { - justify-content: center; - - text-align: center; - } - - .end-lg { - justify-content: flex-end; - - text-align: end; - } - - .top-lg { - align-items: flex-start; - } - - .middle-lg { - align-items: center; - } - - .baseline-lg { - align-items: baseline; - } - - .bottom-lg { - align-items: flex-end; - } - - .around-lg { - justify-content: space-around; - } - - .between-lg { - justify-content: space-between; - } - - .first-lg { - order: -1; - } - - .last-lg { - order: 1; - } -} diff --git a/src/grid/grid.stories.tsx b/src/grid/grid.stories.tsx deleted file mode 100644 index f9a935c1f98..00000000000 --- a/src/grid/grid.stories.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import {Grid, Row, Col} from './grid'; - -export default { - title: 'Components/Grid', - - parameters: { - notes: - 'Implements a flexbox-like grid system for components placement. Inspired by react-flexbox-grid, see http://roylee0704.github.io/react-flexbox-grid/ and http://flexboxgrid.com/ for additional information.', - }, -}; - -export const responsive = () => ( - - - -
Cell 1
- - -
Cell 2
- - -
Cell 3
- -
-
-); - -responsive.storyName = 'responsive'; - -responsive.parameters = { - storyStyles: ` -`, -}; - -export const offset = () => ( -
-

Offset a column.

- - - -
- xsOffset={11} xs={1} -
- - -
- xsOffset={10} xs={2} -
- - -
- xsOffset={9} xs={3} -
- - -
- xsOffset={8} xs={4} -
- - -
- xsOffset={7} xs={5} -
- - -
- xsOffset={6} xs={6} -
- - -
- xsOffset={5} xs={7} -
- - -
- xsOffset={4} xs={8} -
- - -
- xsOffset={3} xs={9} -
- - -
- xsOffset={2} xs={10} -
- - -
- xsOffset={1} xs={11} -
- -
-
-
-); - -offset.storyName = 'offset'; - -offset.parameters = { - storyStyles: ` -`, -}; - -export const autoSize = () => ( -
-

Auto Width: add any number of auto sizing columns to a row. Let the grid figure it out.

- - - -
Autosize
- - -
Autosize column with larger text
- -
- - -
Autosize
- - -
Autosize column with much much much larger text
- - -
Autosize
- -
-
-
-); - -autoSize.storyName = 'auto size'; - -autoSize.parameters = { - storyStyles: ` -`, -}; - -export const alignment = () => ( -
-

Add classes to align elements to the start or end of row as well as the top, bottom, or center of a column

- - - - - - start - - - - - - - - - center - - - - - - - - - end - - - - - - - - -
top
- - -
- - - - - - - - -
middle
- - -
- - - - - - - - -
bottom
- - -
- - - - - -
-); - -alignment.storyName = 'alignment'; - -alignment.parameters = { - storyStyles: ` -`, -}; - -export const columnsDistribution = () => ( -
-

Distribution: add classes to distribute the contents of a row or column.

- - - -
around
- - -
around
- - -
around
- -
- - -
between
- - -
between
- - -
between
- -
-
-
-); - -columnsDistribution.storyName = 'columns distribution'; - -columnsDistribution.parameters = { - storyStyles: ` -`, -}; diff --git a/src/grid/grid.test.tsx b/src/grid/grid.test.tsx deleted file mode 100644 index 82383245496..00000000000 --- a/src/grid/grid.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import {render, screen} from '@testing-library/react'; - -import {Col, Grid, Row, type GridProps} from './grid'; -import {type RowProps} from './row'; -import {type ColProps} from './col'; - -import styles from './grid.css'; - -describe('Grid', () => { - const renderGrid = (props?: GridProps) => render(); - - it('should create component', () => { - renderGrid(); - expect(screen.getByTestId('ring-grid')).to.exist; - }); - - it('should wrap children with div', () => { - renderGrid(); - expect(screen.getByTestId('ring-grid')).to.have.tagName('div'); - }); - - it('should use passed className', () => { - renderGrid({className: 'test-class'}); - expect(screen.getByTestId('ring-grid')).to.have.class('test-class'); - }); - - it('should merge external data-test with default', () => { - renderGrid({'data-test': 'my-grid'}); - expect(screen.getByTestId('ring-grid my-grid')).to.exist; - }); - - it('should pass DOM props to div', () => { - renderGrid({id: 'grid-id'}); - expect(screen.getByTestId('ring-grid')).to.have.attr('id', 'grid-id'); - }); -}); - -describe('Row', () => { - const renderRow = (props?: RowProps) => render(); - - it('should create component', () => { - renderRow(); - expect(screen.getByTestId('ring-grid-row')).to.exist; - }); - - it('should wrap children with div', () => { - renderRow(); - expect(screen.getByTestId('ring-grid-row')).to.have.tagName('div'); - }); - - it('should use passed className', () => { - renderRow({className: 'test-class'}); - expect(screen.getByTestId('ring-grid-row')).to.have.class('test-class'); - }); - - it('should convert "center" modifier to appropriate className', () => { - renderRow({center: 'md'}); - expect(screen.getByTestId('ring-grid-row')).to.have.class(styles['center-md']); - }); - - it('should convert "reverse" modifier to appropriate className', () => { - renderRow({reverse: true}); - expect(screen.getByTestId('ring-grid-row')).to.have.class(styles.reverse); - }); - - it('should merge external data-test with default', () => { - renderRow({'data-test': 'my-row'}); - expect(screen.getByTestId('ring-grid-row my-row')).to.exist; - }); - - it('should pass DOM props to div', () => { - renderRow({id: 'row-id'}); - expect(screen.getByTestId('ring-grid-row')).to.have.attr('id', 'row-id'); - }); - - it('should not pass row-specific props to DOM', () => { - renderRow({center: 'md', start: 'lg'}); - const el = screen.getByTestId('ring-grid-row'); - expect(el).to.not.have.attr('center'); - expect(el).to.not.have.attr('start'); - }); -}); - -describe('Col', () => { - const renderCol = (props?: ColProps) => render(); - - it('should create component', () => { - renderCol(); - expect(screen.getByTestId('ring-grid-column')).to.exist; - }); - - it('should wrap children with div', () => { - renderCol(); - expect(screen.getByTestId('ring-grid-column')).to.have.tagName('div'); - }); - - it('should use passed className', () => { - renderCol({className: 'test-class'}); - expect(screen.getByTestId('ring-grid-column')).to.have.class('test-class'); - }); - - it('should convert digital value to appropriate className', () => { - renderCol({xs: 2}); - expect(screen.getByTestId('ring-grid-column')).to.have.class(styles['col-xs-2']); - }); - - it('should convert autosize to appropriate className', () => { - renderCol({xs: true}); - expect(screen.getByTestId('ring-grid-column')).to.have.class(styles['col-xs']); - }); - - it('should only add classes for provided size props', () => { - renderCol({xs: 2}); - const el = screen.getByTestId('ring-grid-column'); - expect(el).to.have.class(styles['col-xs-2']); - expect(el).to.not.have.class(styles['col-sm']); - expect(el).to.not.have.class(styles['col-md']); - expect(el).to.not.have.class(styles['col-lg']); - }); - - it('should not add any size classes when no size props are provided', () => { - renderCol(); - const el = screen.getByTestId('ring-grid-column'); - expect(el).to.not.have.class(styles['col-xs']); - expect(el).to.not.have.class(styles['col-sm']); - expect(el).to.not.have.class(styles['col-md']); - expect(el).to.not.have.class(styles['col-lg']); - }); - - it('should merge external data-test with default', () => { - renderCol({'data-test': 'my-col'}); - expect(screen.getByTestId('ring-grid-column my-col')).to.exist; - }); - - it('should pass DOM props to div', () => { - renderCol({id: 'col-id'}); - expect(screen.getByTestId('ring-grid-column')).to.have.attr('id', 'col-id'); - }); - - it('should not pass col-specific props to DOM', () => { - renderCol({xs: 2, xsOffset: 1}); - const el = screen.getByTestId('ring-grid-column'); - expect(el).to.not.have.attr('xs'); - expect(el).to.not.have.attr('xsOffset'); - }); -}); diff --git a/src/grid/grid.tsx b/src/grid/grid.tsx deleted file mode 100644 index 51c4bfe6960..00000000000 --- a/src/grid/grid.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import {Component, type HTMLAttributes} from 'react'; -import classNames from 'classnames'; - -import dataTests from '../global/data-tests'; - -import styles from './grid.css'; - -export interface GridProps extends HTMLAttributes { - 'data-test'?: string | null | undefined; -} - -/** - * @name Grid - * @deprecated Will be removed in Ring UI 8.0. Use flexbox or another layout library instead. - */ - -export class Grid extends Component { - render() { - const {children, className, 'data-test': dataTest, ...restProps} = this.props; - const classes = classNames(styles['container-fluid'], className); - - return ( -
- {children} -
- ); - } -} - -export {default as Row} from './row'; -export {default as Col} from './col'; diff --git a/src/grid/row.tsx b/src/grid/row.tsx deleted file mode 100644 index 3f5984fb58b..00000000000 --- a/src/grid/row.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import {Component, type HTMLAttributes} from 'react'; -import classNames from 'classnames'; - -import dataTests from '../global/data-tests'; - -import styles from './grid.css'; - -const modifierKeys = [ - 'start', - 'center', - 'end', // text-align, justify-content - 'around', - 'between', // justify-content - 'top', - 'middle', - 'baseline', - 'bottom', // align-items - 'first', - 'last', // order -] as const; - -type ModifierType = 'xs' | 'sm' | 'md' | 'lg'; - -export interface RowProps extends HTMLAttributes { - 'data-test'?: string | null | undefined; - reverse?: boolean | null | undefined; - start?: ModifierType | null | undefined; - center?: ModifierType | null | undefined; - end?: ModifierType | null | undefined; - top?: ModifierType | null | undefined; - middle?: ModifierType | null | undefined; - baseline?: ModifierType | null | undefined; - bottom?: ModifierType | null | undefined; - around?: ModifierType | null | undefined; - between?: ModifierType | null | undefined; - first?: ModifierType | null | undefined; - last?: ModifierType | null | undefined; -} - -/** - * Converts xs="middle" to class "middle-xs" - * @param {Object} props incoming props - * @mockReturnValue {Array} result modifier classes - */ -function getModifierClassNames(props: RowProps) { - return modifierKeys.reduce((result: string[], key) => { - if (props[key]) { - return result.concat(styles[`${key}-${props[key]}`]); - } - return result; - }, []); -} - -export default class Row extends Component { - render() { - const { - children, - className, - 'data-test': dataTest, - reverse, - start, - center, - end, - top, - middle, - baseline, - bottom, - around, - between, - first, - last, - ...restProps - } = this.props; - - const classes = classNames( - className, - styles.row, - getModifierClassNames({start, center, end, top, middle, baseline, bottom, around, between, first, last}), - { - [styles.reverse]: reverse, - }, - ); - - return ( -
- {children} -
- ); - } -} diff --git a/src/icon/icon.css b/src/icon/icon.css index 19c3cb4d545..b5f67c1ef45 100755 --- a/src/icon/icon.css +++ b/src/icon/icon.css @@ -11,11 +11,6 @@ pointer-events: none; - /* TODO remove in 8.0 */ - &[width='10'] { - vertical-align: -1px; - } - &[width='12'] { vertical-align: -1px; } @@ -71,25 +66,3 @@ .white { color: var(--ring-white-text-color); } - -.loading { - animation-name: icon-loading; - animation-duration: 1200ms; - animation-iteration-count: infinite; -} - -@keyframes icon-loading { - 0% { - transform: scale(1); - } - - 50% { - transform: scale(0.9); - - opacity: 0.5; - } - - 100% { - transform: scale(1); - } -} diff --git a/src/icon/icon.tsx b/src/icon/icon.tsx index 22b6c7e32ed..bcbc0643c85 100755 --- a/src/icon/icon.tsx +++ b/src/icon/icon.tsx @@ -35,7 +35,6 @@ export interface IconProps extends HTMLAttributes { * @deprecated Use icons with appropriate intrinsic sizes instead */ width?: number | undefined; - loading?: boolean | null | undefined; // TODO: remove in 8.0 suppressSizeWarning?: boolean | null | undefined; } @@ -76,30 +75,13 @@ export default class Icon extends PureComponent { } render() { - const { - className, - size, - color, - loading, - glyph: Glyph, - width, - height, - suppressSizeWarning, - ...restProps - } = this.props; + const {className, size, color, glyph: Glyph, width, height, suppressSizeWarning, ...restProps} = this.props; if (!Glyph) { return null; } - const classes = classNames( - styles.icon, - color && styles[color], - { - [styles.loading]: loading, - }, - className, - ); + const classes = classNames(styles.icon, color && styles[color], className); return ( diff --git a/src/legacy-table/table.stories.tsx b/src/legacy-table/table.stories.tsx index b2c52509251..8fa0d9d3760 100644 --- a/src/legacy-table/table.stories.tsx +++ b/src/legacy-table/table.stories.tsx @@ -1,7 +1,6 @@ import {useState, useReducer} from 'react'; import {type StoryFn} from '@storybook/react-webpack5'; -import {Grid, Row, Col} from '../grid/grid'; import Link from '../link/link'; import Pager from '../pager/pager'; import Button from '../button/button'; @@ -132,53 +131,37 @@ export const Basic: StoryFn = args => { dragHandleTitle='Drag me!' /> - - - - - - - - - Active items: {[...selection.getActive()].map(item => item.country).join(', ')} - - - - - - {page === 1 && data.length > 5 && ( - <> +
+
+ +
+ +
Active items: {[...selection.getActive()].map(item => item.country).join(', ')}
+ +
+ + {page === 1 && data.length > 5 && ( + <> + {' '} + + {selection.isSelected(data[3]) ? ( + + ) : ( + + )} + + {' '} - - {selection.isSelected(data[3]) ? ( - - ) : ( - - )} - - - {' '} - {selection.isSelected(data[5]) ? ( - - ) : ( - - )} - - - )} - - - + {selection.isSelected(data[5]) ? ( + + ) : ( + + )} + + + )} +
+
); }; diff --git a/src/list/consts.ts b/src/list/consts.ts index 69498b55644..07ce6adaf8e 100644 --- a/src/list/consts.ts +++ b/src/list/consts.ts @@ -11,7 +11,6 @@ export enum Type { SEPARATOR = 0, LINK = 1, ITEM = 2, - HINT = 3, // doesn't work, TODO remove in 8.0 CUSTOM = 4, TITLE = 5, MARGIN = 6, diff --git a/src/list/list.stories.tsx b/src/list/list.stories.tsx index e89e4781c11..5725d3c52ae 100644 --- a/src/list/list.stories.tsx +++ b/src/list/list.stories.tsx @@ -8,7 +8,6 @@ import Loader from '../loader/loader'; import Tooltip from '../tooltip/tooltip'; import Auth from '../auth/auth'; import Code from '../code/code'; -import ContentLayout, {Sidebar} from '../content-layout/content-layout'; import Link from '../link/link'; import List, {type ListAttrs} from './list'; import Source from './list-users-groups-source'; @@ -252,19 +251,21 @@ export const WithUsers = () => { }, []); return listData ? ( - - - - + <> + {selected && } - + ) : ( ); }; WithUsers.storyName = 'with users'; -WithUsers.parameters = {screenshots: {skip: true}}; +WithUsers.parameters = { + screenshots: {skip: true}, + storyStyles: + '', +}; WithUsers.tags = ['skip-test']; export const withCustomTooltip: StoryFn = args => ; diff --git a/src/list/list.tsx b/src/list/list.tsx index 1c340ffa369..55c3a165c7c 100644 --- a/src/list/list.tsx +++ b/src/list/list.tsx @@ -96,10 +96,6 @@ export interface ListProps { maxHeight?: number | null | undefined; activeIndex?: number | null | undefined; useMouseUp?: boolean | null | undefined; - /** - * @deprecated No longer used. Visibility is detected automatically via IntersectionObserver. Will be removed in Ring UI 8.0. - */ - visible?: boolean | null | undefined; disableMoveOverflow?: boolean | null | undefined; compact?: boolean | null | undefined; disableScrollToActive?: boolean | null | undefined; diff --git a/src/old-browsers-message/__mocks__/old-browsers-message.js b/src/old-browsers-message/__mocks__/old-browsers-message.js index 7e0ca391af8..e69de29bb2d 100644 --- a/src/old-browsers-message/__mocks__/old-browsers-message.js +++ b/src/old-browsers-message/__mocks__/old-browsers-message.js @@ -1 +0,0 @@ -export function stop() {} diff --git a/src/old-browsers-message/old-browsers-message-stop.ts b/src/old-browsers-message/old-browsers-message-stop.ts deleted file mode 100644 index 4526520fc3d..00000000000 --- a/src/old-browsers-message/old-browsers-message-stop.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export for backward compatibility -export {stop as default} from './old-browsers-message'; diff --git a/src/old-browsers-message/old-browsers-message.stories.tsx b/src/old-browsers-message/old-browsers-message.stories.tsx index b5bc69d3b14..99a67f06495 100644 --- a/src/old-browsers-message/old-browsers-message.stories.tsx +++ b/src/old-browsers-message/old-browsers-message.stories.tsx @@ -1,7 +1,7 @@ import './old-browsers-message.css'; import {useEffect} from 'react'; -import {stop} from './old-browsers-message'; +import './old-browsers-message'; export default { title: 'Style-only/Old Browsers Message', @@ -11,7 +11,7 @@ export default { Displays a full-screen "Browser is unsupported" message if a JavaScript error occurs on page load in an old browser. Note: this script does not have any dependencies, you should include it directly. -Once loaded, it attaches a global error handler. When your app finishes loading you should probably turn it off by calling oldBrowserMessage.stop(); +Once loaded, it attaches a global error handler. `, }, }; @@ -19,7 +19,6 @@ Once loaded, it attaches a global error handler. When your app finishes loading function triggerGlobalError() { // @ts-expect-error testing a runtime error Object.unknownMethodToTriggerOldBrowsersMessage(); - setTimeout(stop); } export const Basic = () => { diff --git a/src/old-browsers-message/old-browsers-message.ts b/src/old-browsers-message/old-browsers-message.ts index 073f6cd039b..4227d2b9e9c 100644 --- a/src/old-browsers-message/old-browsers-message.ts +++ b/src/old-browsers-message/old-browsers-message.ts @@ -68,13 +68,6 @@ function startOldBrowsersDetector(onOldBrowserDetected?: () => void) { }; } -/** - * @deprecated Will be removed in Ring UI 8.0. - */ -function stopOldBrowserDetector() { - window.onerror = previousWindowErrorHandler; -} - //Start javascript error detection startOldBrowsersDetector(() => { const oldBrowsersMessageContainer = document.getElementById('ring-old-browsers-message'); @@ -101,5 +94,3 @@ startOldBrowsersDetector(() => { attachSmileClickListener(smileNode); } }); - -export {stopOldBrowserDetector as stop}; diff --git a/src/select/select.tsx b/src/select/select.tsx index 3141f9b5011..32ff107fd76 100644 --- a/src/select/select.tsx +++ b/src/select/select.tsx @@ -75,11 +75,7 @@ type SelectItemData = T & { export type SelectItem = ListDataItem>; function getLowerCaseLabel(item: SelectItem) { - if ( - List.isItemType(List.ListProps.Type.SEPARATOR, item) || - List.isItemType(List.ListProps.Type.HINT, item) || - typeof item.label !== 'string' - ) { + if (List.isItemType(List.ListProps.Type.SEPARATOR, item) || typeof item.label !== 'string') { return null; } From 81d5c7ee9d0a2fc78c3b8e05f6a3ce8c1c8c538f Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 7 Aug 2026 14:08:21 +0200 Subject: [PATCH 17/36] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d7d74f2b7..ded4fc6fbce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Removed the `useEventCallback()` custom hook; use the `useEffectEvent()` React hook instead. - Removed the deprecated `Grid`, `Row`, `Col`, `ContentLayout`, and `Sidebar` components; use CSS flexbox, CSS grid, or another layout library instead. - Removed the deprecated `Button` `text` prop; use `inline` instead. -- Removed the deprecated `Icon` `loading` prop and `ListDataItemType.HINT`. +- Removed the deprecated `Icon` `loading` prop and `List.ListProps.Type.HINT`. - Removed the deprecated `List` `visible` prop; visibility is detected automatically with `IntersectionObserver`. - Removed the deprecated `Avatar` sizes 18 and 48; use another supported size instead. - Removed the deprecated `old-browsers-message` `stop` export. From b758afa62c9a5cf58da461d8bc73a9c1255a6acc Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 7 Aug 2026 14:14:21 +0200 Subject: [PATCH 18/36] Address 8.0 removal review feedback --- CHANGELOG.md | 2 +- package-lock.json | 42 +------- package.json | 1 - scripts/console-errors.test.js | 5 +- scripts/prepare-built-package.js | 1 - src/list/list.stories.tsx | 6 +- .../__mocks__/old-browsers-message.js | 0 .../old-browsers-message.css | 26 ----- .../old-browsers-message.stories.tsx | 47 --------- .../old-browsers-message.ts | 96 ------------------- src/old-browsers-message/white-list.ts | 32 ------- 11 files changed, 9 insertions(+), 249 deletions(-) delete mode 100644 src/old-browsers-message/__mocks__/old-browsers-message.js delete mode 100644 src/old-browsers-message/old-browsers-message.css delete mode 100644 src/old-browsers-message/old-browsers-message.stories.tsx delete mode 100644 src/old-browsers-message/old-browsers-message.ts delete mode 100644 src/old-browsers-message/white-list.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ded4fc6fbce..48c563f315f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Removed the deprecated `Icon` `loading` prop and `List.ListProps.Type.HINT`. - Removed the deprecated `List` `visible` prop; visibility is detected automatically with `IntersectionObserver`. - Removed the deprecated `Avatar` sizes 18 and 48; use another supported size instead. -- Removed the deprecated `old-browsers-message` `stop` export. +- Removed the deprecated `old-browsers-message` module. - Removed the deprecated `expand/collapsible-group` component and CSS import aliases; import them from `collapsible-group/collapsible-group` instead. - Removed the deprecated `--ring-border-disabled-active-color`, `--ring-action-link-color`, and `--ring-button-primary-background-color` CSS variables; use `--ring-border-hover-color`, `--ring-link-color`, and `--ring-main-color`, respectively. - Removed the deprecated `--ring-pinned-shadow-color`, `--ring-hint-color`, and `--ring-button-loader-background` CSS variables. diff --git a/package-lock.json b/package-lock.json index 9926cb2eb02..aebaf1b89cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,6 @@ "react-compiler-runtime": "^1.0.0", "react-movable": "^3.4.1", "react-virtualized": "^9.22.6", - "react-waypoint": "^10.3.0", "scrollbar-width": "^3.1.1", "simply-uuid": "^1.0.1", "sniffr": "^1.4.0", @@ -17619,11 +17618,6 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, - "node_modules/consolidated-events": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/consolidated-events/-/consolidated-events-2.0.2.tgz", - "integrity": "sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ==" - }, "node_modules/constant-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", @@ -34716,7 +34710,8 @@ "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "node_modules/react-is-18": { "name": "react-is", @@ -34788,20 +34783,6 @@ "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-waypoint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/react-waypoint/-/react-waypoint-10.3.0.tgz", - "integrity": "sha512-iF1y2c1BsoXuEGz08NoahaLFIGI9gTUAAOKip96HUmylRT6DUtpgoBPjk/Y8dfcFVmfVDvUzWjNXpZyKTOV0SQ==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "consolidated-events": "^1.1.0 || ^2.0.0", - "prop-types": "^15.0.0", - "react-is": "^17.0.1 || ^18.0.0" - }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -52666,11 +52647,6 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, - "consolidated-events": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/consolidated-events/-/consolidated-events-2.0.2.tgz", - "integrity": "sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ==" - }, "constant-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", @@ -64253,7 +64229,8 @@ "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "react-is-18": { "version": "npm:react-is@18.3.1", @@ -64300,17 +64277,6 @@ "react-lifecycles-compat": "^3.0.4" } }, - "react-waypoint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/react-waypoint/-/react-waypoint-10.3.0.tgz", - "integrity": "sha512-iF1y2c1BsoXuEGz08NoahaLFIGI9gTUAAOKip96HUmylRT6DUtpgoBPjk/Y8dfcFVmfVDvUzWjNXpZyKTOV0SQ==", - "requires": { - "@babel/runtime": "^7.12.5", - "consolidated-events": "^1.1.0 || ^2.0.0", - "prop-types": "^15.0.0", - "react-is": "^17.0.1 || ^18.0.0" - } - }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index e0762a5af3e..f3c26313972 100644 --- a/package.json +++ b/package.json @@ -251,7 +251,6 @@ "react-compiler-runtime": "^1.0.0", "react-movable": "^3.4.1", "react-virtualized": "^9.22.6", - "react-waypoint": "^10.3.0", "scrollbar-width": "^3.1.1", "simply-uuid": "^1.0.1", "sniffr": "^1.4.0", diff --git a/scripts/console-errors.test.js b/scripts/console-errors.test.js index 80f33dea7d4..d34ed17c49c 100644 --- a/scripts/console-errors.test.js +++ b/scripts/console-errors.test.js @@ -11,11 +11,8 @@ jest.mock( destroy = jest.fn(); }, ); -jest.mock('../src/old-browsers-message/old-browsers-message'); - const options = { suite: 'Console errors', - storyKindRegex: /^((?!Style-only\/Old Browsers Message).)*$/, // storyNameRegex: /^with deprecated item\.type parameter$/, }; @@ -23,7 +20,7 @@ describe(options.suite, () => { getAllStoryFiles().forEach(({storyFile, title}) => { const meta = storyFile.default; - if ((options.storyKindRegex && !options.storyKindRegex.test(title)) || meta.parameters?.storyshots?.disable) { + if (meta.parameters?.storyshots?.disable) { return; } diff --git a/scripts/prepare-built-package.js b/scripts/prepare-built-package.js index 833c75d47ae..206a2a9b6fd 100644 --- a/scripts/prepare-built-package.js +++ b/scripts/prepare-built-package.js @@ -27,7 +27,6 @@ const WHITE_LIST = [ 'react-compiler-runtime', 'react-movable', 'react-virtualized', - 'react-waypoint', 'scrollbar-width', 'simply-uuid', 'sniffr', diff --git a/src/list/list.stories.tsx b/src/list/list.stories.tsx index 5725d3c52ae..be9985ea71c 100644 --- a/src/list/list.stories.tsx +++ b/src/list/list.stories.tsx @@ -251,10 +251,10 @@ export const WithUsers = () => { }, []); return listData ? ( - <> +
{selected && } - +
) : ( ); @@ -264,7 +264,7 @@ WithUsers.storyName = 'with users'; WithUsers.parameters = { screenshots: {skip: true}, storyStyles: - '', + '', }; WithUsers.tags = ['skip-test']; diff --git a/src/old-browsers-message/__mocks__/old-browsers-message.js b/src/old-browsers-message/__mocks__/old-browsers-message.js deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/old-browsers-message/old-browsers-message.css b/src/old-browsers-message/old-browsers-message.css deleted file mode 100644 index e0a7263ba68..00000000000 --- a/src/old-browsers-message/old-browsers-message.css +++ /dev/null @@ -1,26 +0,0 @@ -@import '../global/variables.css'; - -:global(.ring-old-browsers-message) { - display: block; - - margin-top: 15%; - - text-align: center; - - color: var(--ring-text-color); - - font-family: system-ui, Ubuntu, 'Helvetica Neue', Arial, sans-serif; - font-size: var(--ring-font-size-larger); - line-height: calc(2.5 * var(--ring-unit)); -} - -:global(.ring-old-browsers-message_hidden) { - display: none; -} - -:global(.ring-old-browsers-message__smile) { - cursor: pointer; - user-select: none; - - font-size: calc(3 * var(--ring-unit)); -} diff --git a/src/old-browsers-message/old-browsers-message.stories.tsx b/src/old-browsers-message/old-browsers-message.stories.tsx deleted file mode 100644 index 99a67f06495..00000000000 --- a/src/old-browsers-message/old-browsers-message.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import './old-browsers-message.css'; -import {useEffect} from 'react'; - -import './old-browsers-message'; - -export default { - title: 'Style-only/Old Browsers Message', - - parameters: { - notes: ` -Displays a full-screen "Browser is unsupported" message if a JavaScript error occurs on page load in an old browser. - -Note: this script does not have any dependencies, you should include it directly. -Once loaded, it attaches a global error handler. - `, - }, -}; - -function triggerGlobalError() { - // @ts-expect-error testing a runtime error - Object.unknownMethodToTriggerOldBrowsersMessage(); -} - -export const Basic = () => { - useEffect(() => { - setTimeout(triggerGlobalError); - }, []); - - return ( - - ); -}; - -Basic.storyName = 'Old Browsers Message'; diff --git a/src/old-browsers-message/old-browsers-message.ts b/src/old-browsers-message/old-browsers-message.ts deleted file mode 100644 index 4227d2b9e9c..00000000000 --- a/src/old-browsers-message/old-browsers-message.ts +++ /dev/null @@ -1,96 +0,0 @@ -import {isBrowserInWhiteList} from './white-list'; - -/** - * @name Old Browsers Message - */ - -/** - The list of versions which are definitely supported. "Browser is unsupported" - won't be displayed for those and higher versions even when a JS error occurs - on application start. - */ - -let smileChanges = 0; -const MAX_SMILE_CHANGES = 50; -let previousWindowErrorHandler: OnErrorEventHandler; - -function changeSmileClickListener(event: Event) { - const eyes = ['O', 'o', '-', '>', '<']; - const target = (event.target || event.srcElement) as HTMLElement; - - smileChanges++; - - function rand(min: number, max: number) { - return Math.round(Math.random() * (max - min)) + min; - } - - function getRandomEye() { - return eyes[rand(0, eyes.length - 1)]; - } - - function getRandomSmile() { - if (smileChanges >= MAX_SMILE_CHANGES) { - return '\\\\ (x_x) //'; - } - - return `{{ (${getRandomEye()}_${getRandomEye()}) }}`; - } - - target.innerHTML = getRandomSmile(); -} - -function attachSmileClickListener(smileNode: Node) { - if (smileNode.addEventListener) { - smileNode.addEventListener('click', changeSmileClickListener); - /* eslint-disable @typescript-eslint/no-explicit-any */ - } else if ((smileNode as any).attachEvent) { - (smileNode as any).attachEvent('onclick', changeSmileClickListener); - /* eslint-enable */ - } -} - -/** - * Listens to unhandled errors and displays passed node - */ -function startOldBrowsersDetector(onOldBrowserDetected?: () => void) { - previousWindowErrorHandler = window.onerror; - - window.onerror = function oldBrowsersMessageShower(errorMsg, url, lineNumber) { - if (onOldBrowserDetected) { - onOldBrowserDetected(); - } - - if (previousWindowErrorHandler) { - return previousWindowErrorHandler(errorMsg, url, lineNumber); - } - - return false; - }; -} - -//Start javascript error detection -startOldBrowsersDetector(() => { - const oldBrowsersMessageContainer = document.getElementById('ring-old-browsers-message'); - const browserMessage = document.getElementById('ring-old-browsers-message__browser-message'); - const errorMessage = document.getElementById('ring-old-browsers-message__error-message'); - const smileNode = document.getElementById('ring-old-browsers-message__smile'); - - if (browserMessage && errorMessage) { - if (isBrowserInWhiteList()) { - browserMessage.style.display = 'none'; - errorMessage.style.display = 'block'; - } else { - browserMessage.style.display = 'block'; - errorMessage.style.display = 'none'; - } - } - - if (oldBrowsersMessageContainer) { - oldBrowsersMessageContainer.hidden = false; - oldBrowsersMessageContainer.style.display = 'block'; - } - - if (smileNode) { - attachSmileClickListener(smileNode); - } -}); diff --git a/src/old-browsers-message/white-list.ts b/src/old-browsers-message/white-list.ts deleted file mode 100644 index d1bb2ab6a04..00000000000 --- a/src/old-browsers-message/white-list.ts +++ /dev/null @@ -1,32 +0,0 @@ -import sniffer from '../global/sniffer'; - -const MAJOR_VERSION_INDEX = 0; - -declare const SUPPORTED_BROWSERS: string[] | undefined; - -/** - * SUPPORTED_BROWSERS are defined by Babel plugin, see babel config - */ -if (!SUPPORTED_BROWSERS) { - // eslint-disable-next-line no-console - console.warn('Ring UI: no SUPPORTED_BROWSERS passed. Please check babel config.'); -} -const SUPPORTED = SUPPORTED_BROWSERS || []; - -const WHITE_LISTED_BROWSERS = ['chrome', 'firefox', 'safari', 'edge']; - -export const WHITE_LIST = SUPPORTED.reduce((acc: Record, item) => { - const [, browserName, version] = item.match(/(\S+)\s(\S+)/) ?? []; - if (!WHITE_LISTED_BROWSERS.includes(browserName)) { - return acc; - } - - return { - ...acc, - [browserName]: parseInt(version, 10), - }; -}, {}); - -export function isBrowserInWhiteList() { - return sniffer.browser.version[MAJOR_VERSION_INDEX] >= WHITE_LIST[sniffer.browser.name]; -} From d078ace763aee69c4a7da6387677c1be16dd2de3 Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 7 Aug 2026 17:27:37 +0200 Subject: [PATCH 19/36] Remove obsolete browser constant transform --- babel.config.js | 8 -------- package-lock.json | 39 ++++----------------------------------- package.json | 2 -- 3 files changed, 4 insertions(+), 45 deletions(-) diff --git a/babel.config.js b/babel.config.js index 20d68b93128..a6ecd36882e 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,5 +1,3 @@ -const browserslist = require('browserslist'); - module.exports = function config(api) { api.cache(true); @@ -12,12 +10,6 @@ module.exports = function config(api) { panicThreshold: 'all_errors', }, ], - [ - 'babel-plugin-transform-define', - { - SUPPORTED_BROWSERS: browserslist(), - }, - ], ], presets: [ [ diff --git a/package-lock.json b/package-lock.json index aebaf1b89cb..0d8c63110b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,6 @@ "@types/util-deprecate": "^1.0.4", "babel-loader": "10.1.1", "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-transform-define": "^2.1.4", - "browserslist": "^4.28.4", "change-case": "^4.1.1", "classnames": "^2.5.1", "combokeys": "^3.0.1", @@ -16022,18 +16020,6 @@ "integrity": "sha512-EMZD1563QUqLhzrqcThk759RhuNVX/ZJdrtGK6drwzgvnR+ARjWyXIHPbu+tUNaMGtPz/gQeAM2M6VUw2UiUeA==", "dev": true }, - "node_modules/babel-plugin-transform-define": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-define/-/babel-plugin-transform-define-2.1.4.tgz", - "integrity": "sha512-NN9xFmyNvr4swPZkRWy+RZZoV0yHhPk/WoxpuIvcVkTyYf0xy/JTQeZVbVGX8hyJ0/NKKuxnt4BZz9No7BziVA==", - "dependencies": { - "lodash": "^4.17.11", - "traverse": "0.6.6" - }, - "engines": { - "node": ">= 8.x.x" - } - }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -29258,7 +29244,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "node_modules/lodash.camelcase": { "version": "4.3.0", @@ -39201,11 +39188,6 @@ "node": ">= 4.0.0" } }, - "node_modules/traverse": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", - "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -51511,15 +51493,6 @@ "integrity": "sha512-EMZD1563QUqLhzrqcThk759RhuNVX/ZJdrtGK6drwzgvnR+ARjWyXIHPbu+tUNaMGtPz/gQeAM2M6VUw2UiUeA==", "dev": true }, - "babel-plugin-transform-define": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-define/-/babel-plugin-transform-define-2.1.4.tgz", - "integrity": "sha512-NN9xFmyNvr4swPZkRWy+RZZoV0yHhPk/WoxpuIvcVkTyYf0xy/JTQeZVbVGX8hyJ0/NKKuxnt4BZz9No7BziVA==", - "requires": { - "lodash": "^4.17.11", - "traverse": "0.6.6" - } - }, "babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -60781,7 +60754,8 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "lodash.camelcase": { "version": "4.3.0", @@ -67271,11 +67245,6 @@ } } }, - "traverse": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", - "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" - }, "tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index f3c26313972..82efefda64f 100644 --- a/package.json +++ b/package.json @@ -226,8 +226,6 @@ "@types/util-deprecate": "^1.0.4", "babel-loader": "10.1.1", "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-transform-define": "^2.1.4", - "browserslist": "^4.28.4", "change-case": "^4.1.1", "classnames": "^2.5.1", "combokeys": "^3.0.1", From 25520a20274edd8002bac3c66e7f02088052bfde Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 7 Aug 2026 17:31:41 +0200 Subject: [PATCH 20/36] Update component screenshots --- .../testplane/chrome/components/button/basic/basic-dark.png | 4 ++-- .../chrome/components/button/basic/basic-focus active.png | 4 ++-- .../testplane/chrome/components/button/basic/basic.png | 4 ++-- .../components/icon/all icons list/all icons list-dark.png | 4 ++-- .../chrome/components/icon/all icons list/all icons list.png | 4 ++-- .../theme palette/theme palette/theme palette-dark.png | 4 ++-- .../style-only/theme palette/theme palette/theme palette.png | 4 ++-- .../testplane/firefox/components/button/basic/basic-dark.png | 4 ++-- .../firefox/components/button/basic/basic-focus active.png | 4 ++-- .../testplane/firefox/components/button/basic/basic.png | 4 ++-- .../components/icon/all icons list/all icons list-dark.png | 4 ++-- .../firefox/components/icon/all icons list/all icons list.png | 4 ++-- .../theme palette/theme palette/theme palette-dark.png | 4 ++-- .../style-only/theme palette/theme palette/theme palette.png | 4 ++-- 14 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/screenshots/testplane/chrome/components/button/basic/basic-dark.png b/packages/screenshots/testplane/chrome/components/button/basic/basic-dark.png index e342b57813b..550c841044e 100644 --- a/packages/screenshots/testplane/chrome/components/button/basic/basic-dark.png +++ b/packages/screenshots/testplane/chrome/components/button/basic/basic-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:39950bc9df0a8db1708d58704da22b808ae758f73a802f2a3a9d0386f7bd17ec -size 555355 +oid sha256:c5bd25c75baf31d868ea43d99c7c0dd24899f8358d49eab85613583f3ebdfd2e +size 555185 diff --git a/packages/screenshots/testplane/chrome/components/button/basic/basic-focus active.png b/packages/screenshots/testplane/chrome/components/button/basic/basic-focus active.png index a7cc7923a7a..e52bcc7c4dc 100644 --- a/packages/screenshots/testplane/chrome/components/button/basic/basic-focus active.png +++ b/packages/screenshots/testplane/chrome/components/button/basic/basic-focus active.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ddaafa3b2e33c463e97455f782fe7475775e78ab2f222aa8ed933ef9e6222d72 -size 549670 +oid sha256:8f5bbe707fc2bb94b56ff0d11633e6b02356af1aa02e3c59820557771d63602f +size 549502 diff --git a/packages/screenshots/testplane/chrome/components/button/basic/basic.png b/packages/screenshots/testplane/chrome/components/button/basic/basic.png index 973d6395843..78e1b1d4154 100644 --- a/packages/screenshots/testplane/chrome/components/button/basic/basic.png +++ b/packages/screenshots/testplane/chrome/components/button/basic/basic.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9b16657434770b81ca377ca47e111b078cdad00f32fda8c6f235f94fbb56e2e -size 549474 +oid sha256:5d6ccd3555e611a9747c3bddb5859af74e34d481d1b29b84f58070966c4a545a +size 549306 diff --git a/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list-dark.png b/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list-dark.png index af43d95ce3e..c69b2395b60 100644 --- a/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list-dark.png +++ b/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a790576beb3c52515826430845712062d3ca8ef17689238b7b30462e40ace902 -size 497650 +oid sha256:d6287d633c11088da21b8ab4e318b59c68b93a9383be85d92a94a91aa6fdb9c8 +size 497657 diff --git a/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list.png b/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list.png index eb6899885b6..81dfcfa228c 100644 --- a/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list.png +++ b/packages/screenshots/testplane/chrome/components/icon/all icons list/all icons list.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22c22c1c63ff1196f45e72ce73df70685b22fda1eb7eea56e885177dd26a15fc -size 498842 +oid sha256:11839abf2905480779d8478de89a9325d6f97cf99f2bd7928c095c581d6446f9 +size 498849 diff --git a/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette-dark.png b/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette-dark.png index e1540a6a9c9..bed736e3b88 100644 --- a/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette-dark.png +++ b/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51c45a0600cfcac3f96e4653077ccc6e17221419c3be2b66ba43297b5876dcca -size 172223 +oid sha256:0f1d9f0e565cd69156612a8d7ead73d95a55a0c430c39a1933da19eadbfa236c +size 165933 diff --git a/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette.png b/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette.png index 5216ace023e..dd669c57a35 100644 --- a/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette.png +++ b/packages/screenshots/testplane/chrome/style-only/theme palette/theme palette/theme palette.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef2b90025c98e76536e1803a18118ea2ddf6125299c25ce1944137123f518af6 -size 173376 +oid sha256:54983de01d0938607c44cdb2c2286b774445a29ed0c25542df7f440dcf6f778a +size 166631 diff --git a/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png b/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png index 5849a90ba7c..7d5e610cf3a 100644 --- a/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png +++ b/packages/screenshots/testplane/firefox/components/button/basic/basic-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a32d60e1cf2e10d33b301c3ac7efeab3fb333001141b2a16056b599a24e7614b -size 847611 +oid sha256:85faf7ad52dc95f67e84e69fca8702e26e3fdda3d93852aca67f2bca6e4fb733 +size 847364 diff --git a/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png b/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png index 5e1dbda4c3c..35d9110412c 100644 --- a/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png +++ b/packages/screenshots/testplane/firefox/components/button/basic/basic-focus active.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aebcc907f7534857f5ba76cb293163ff4140172f1fd09713f31f36757469d953 -size 826113 +oid sha256:5d0cc0d04ab6590c14db36bfa6fe4893de6f137702891f1eeb883340cbab74ab +size 825865 diff --git a/packages/screenshots/testplane/firefox/components/button/basic/basic.png b/packages/screenshots/testplane/firefox/components/button/basic/basic.png index b4f01d55570..28c2ff2660c 100644 --- a/packages/screenshots/testplane/firefox/components/button/basic/basic.png +++ b/packages/screenshots/testplane/firefox/components/button/basic/basic.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29ad3407a06e3259a924b1a6f166f92ff1205b57e25007100acc9592343edcca -size 825878 +oid sha256:19ce14f4c50d3fd1fdc1a264c642639beb680ba436fd25db7ba2a52f05796475 +size 825630 diff --git a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png index 709d9bd42bf..0306b76a85e 100644 --- a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png +++ b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cd613ba5056d386522c5b9ec422bbc510e4d8aa16184546325d109f402b9466a -size 854842 +oid sha256:d2c45e8967180af18ceee69a3f58847f90e7ced6e23692762d0cf5e6ce1329a0 +size 854844 diff --git a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png index 08b3b144fd9..19e1b7c5346 100644 --- a/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png +++ b/packages/screenshots/testplane/firefox/components/icon/all icons list/all icons list.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0cf63bb3b174f6d24bdd518628366b87bb8636a36772ede0ba45e33ef26ea8a6 -size 826879 +oid sha256:632a04a20b5c130d3f86093976ec4568d0fc990ae2a8201c49ceced0b2d9e604 +size 826880 diff --git a/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette-dark.png b/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette-dark.png index 6366bf86494..b1c30270bea 100644 --- a/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette-dark.png +++ b/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e1db7cf9a312c9efee674e7d014743c7b4a25f425722e60626e8d13d7e92f93 -size 375049 +oid sha256:34645fccde4ad3bb66c6c900e732eaab00b8da2143497366c81bc168664d72ac +size 361342 diff --git a/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette.png b/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette.png index 83d4bd14703..7c5a606b9b0 100644 --- a/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette.png +++ b/packages/screenshots/testplane/firefox/style-only/theme palette/theme palette/theme palette.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7907705de14ec6b5aa3560fb7d0587530cfabb5329dc01cfc5cf5e6c2bdde0c6 -size 367851 +oid sha256:79e5662b00c99c21c17f5b88a8aff6385e232a108bbe87cba0421ddc8e09a22f +size 353930 From 50d6ba5c38cbb1ffb2e277bdfb6133ec5efd6ebb Mon Sep 17 00:00:00 2001 From: JetBrains Ring UI Automation Date: Fri, 7 Aug 2026 21:12:27 +0000 Subject: [PATCH 21/36] 8.0.0-beta.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d8c63110b5..aba93f2d002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.6", + "version": "8.0.0-beta.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.6", + "version": "8.0.0-beta.7", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index 82efefda64f..8801f60a238 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jetbrains/ring-ui", - "version": "8.0.0-beta.6", + "version": "8.0.0-beta.7", "description": "JetBrains UI library", "author": { "name": "JetBrains" From 068186918fb2f303f19b9d57e285018dbe108034 Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Mon, 10 Aug 2026 16:42:53 +0200 Subject: [PATCH 22/36] Document CSS variable replacements --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c563f315f..174a21faf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - Removed the deprecated `old-browsers-message` module. - Removed the deprecated `expand/collapsible-group` component and CSS import aliases; import them from `collapsible-group/collapsible-group` instead. - Removed the deprecated `--ring-border-disabled-active-color`, `--ring-action-link-color`, and `--ring-button-primary-background-color` CSS variables; use `--ring-border-hover-color`, `--ring-link-color`, and `--ring-main-color`, respectively. -- Removed the deprecated `--ring-pinned-shadow-color`, `--ring-hint-color`, and `--ring-button-loader-background` CSS variables. +- Removed the deprecated `--ring-pinned-shadow-color`, `--ring-hint-color`, and `--ring-button-loader-background` CSS variables. Use `--ring-popup-shadow-color` or a product-specific token for shadows and `--ring-secondary-color` for muted text, choosing a more specific semantic token where appropriate. Button loader colors are now derived per variant; override the component-scoped `--ring-button-loader-components` RGB value only for custom styling. ## [7.0.121] - Added `keepMounted` prop to `Collapse` and `CollapsibleGroup` that keeps collapsed content mounted (hidden via `visibility: hidden` and `inert`) instead of unmounting it, preserving local state, subscriptions and iframes. Limitations: content rendered through portals (e.g. `Popup`) is not hidden, and hidden form controls still participate in form validation — disable them while collapsed if needed. From 5a03547c495891ccdf5daab90f9795d54cc1bc5f Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Mon, 10 Aug 2026 16:51:06 +0200 Subject: [PATCH 23/36] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 174a21faf56..9160f656ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - Removed the deprecated `old-browsers-message` module. - Removed the deprecated `expand/collapsible-group` component and CSS import aliases; import them from `collapsible-group/collapsible-group` instead. - Removed the deprecated `--ring-border-disabled-active-color`, `--ring-action-link-color`, and `--ring-button-primary-background-color` CSS variables; use `--ring-border-hover-color`, `--ring-link-color`, and `--ring-main-color`, respectively. -- Removed the deprecated `--ring-pinned-shadow-color`, `--ring-hint-color`, and `--ring-button-loader-background` CSS variables. Use `--ring-popup-shadow-color` or a product-specific token for shadows and `--ring-secondary-color` for muted text, choosing a more specific semantic token where appropriate. Button loader colors are now derived per variant; override the component-scoped `--ring-button-loader-components` RGB value only for custom styling. +- Removed the deprecated `--ring-pinned-shadow-color`, `--ring-hint-color`, and `--ring-button-loader-background` CSS variables. Use `--ring-popup-shadow-color` or a product-specific token for shadows and `--ring-secondary-color` for muted text, choosing a more specific semantic token where appropriate. Button loader colors are now derived per variant; override the component-scoped `--ring-button-loader-components` RGB components only for custom styling. ## [7.0.121] - Added `keepMounted` prop to `Collapse` and `CollapsibleGroup` that keeps collapsed content mounted (hidden via `visibility: hidden` and `inert`) instead of unmounting it, preserving local state, subscriptions and iframes. Limitations: content rendered through portals (e.g. `Popup`) is not hidden, and hidden form controls still participate in form validation — disable them while collapsed if needed. From 97caebd52e8d83ff61be05b4787fdc3dd9c49335 Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Fri, 21 Aug 2026 17:25:20 +0200 Subject: [PATCH 24/36] Fix Storybook height on mobile Safari --- .storybook/custom-header/header-styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.storybook/custom-header/header-styles.css b/.storybook/custom-header/header-styles.css index 1417c8e31a6..fbce029c206 100644 --- a/.storybook/custom-header/header-styles.css +++ b/.storybook/custom-header/header-styles.css @@ -7,7 +7,7 @@ */ /* stylelint-disable-next-line selector-max-specificity */ :global(#root) > div { - height: calc(100vh - 64px); + height: calc(100dvh - 64px); } .header { From 6139de721caa2bda0ba751f5dbad1558d7537851 Mon Sep 17 00:00:00 2001 From: Filipp Riabchun Date: Thu, 3 Sep 2026 13:49:51 +0200 Subject: [PATCH 25/36] Add popupProps to Select (#9392) * Add popupProps to Select * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Preserve Select popup behavior * Keep Select popup style precedence --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/select/select-popup.tsx | 9 ++++++--- src/select/select.test.tsx | 24 ++++++++++++++++++++++++ src/select/select.tsx | 4 +++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/select/select-popup.tsx b/src/select/select-popup.tsx index e33b8d4f625..d8f493a2525 100644 --- a/src/select/select-popup.tsx +++ b/src/select/select-popup.tsx @@ -16,7 +16,7 @@ import searchIcon from '@jetbrains/icons/search'; import memoizeOne from 'memoize-one'; import Icon, {type IconType} from '../icon/icon'; -import Popup, {getPopupContainer} from '../popup/popup'; +import Popup, {getPopupContainer, type PopupAttrs} from '../popup/popup'; import {type Directions, maxHeightForDirection} from '../popup/position'; import {normalizePopupTarget, PopupTargetContext} from '../popup/popup.target'; import List, {type SelectHandlerParams} from '../list/list'; @@ -101,6 +101,7 @@ export interface SelectPopupProps { ringPopupTarget: string | null; onSelectAll: (isSelectAll: boolean) => void; onEmptyPopupEnter: (e: KeyboardEvent) => void; + popupProps?: Partial | undefined; className?: string | null | undefined; compact?: boolean | null | undefined; dir?: 'ltr' | 'rtl' | undefined; @@ -582,8 +583,9 @@ export default class SelectPopup extends PureComponent @@ -596,6 +598,7 @@ export default class SelectPopup extends PureComponent