From c3db376875d93f05e95d5953b4590c904193bff6 Mon Sep 17 00:00:00 2001 From: Akash Belide Date: Fri, 26 Jun 2026 18:44:40 -0400 Subject: [PATCH 1/2] Add pin column to all tables via shared PinButton --- src/features/table/CommonColumns.tsx | 24 ++++++++++ .../__tests__/InteractiveEntityTable.test.tsx | 9 ++-- src/features/table/tableStyles.css | 11 +++++ src/features/table/useColumnVisibility.tsx | 6 ++- .../ui/PinButton.tsx} | 15 ++++--- src/shared/ui/pinButton.css | 36 +++++++++++++++ src/widgets/cardlists/CardInCardList.tsx | 4 +- src/widgets/cardlists/cardListStyles.css | 44 +++---------------- 8 files changed, 98 insertions(+), 51 deletions(-) rename src/{widgets/cardlists/CardPinButton.tsx => shared/ui/PinButton.tsx} (51%) create mode 100644 src/shared/ui/pinButton.css diff --git a/src/features/table/CommonColumns.tsx b/src/features/table/CommonColumns.tsx index 485d56f35..fee1e94a6 100644 --- a/src/features/table/CommonColumns.tsx +++ b/src/features/table/CommonColumns.tsx @@ -1,14 +1,38 @@ +import React, { useCallback } from 'react'; + import HoverableObject from '@features/layers/hovercard/HoverableObject'; import { SearchableField } from '@features/params/PageParamTypes'; +import usePageParams from '@features/params/usePageParams'; import Field from '@features/transforms/fields/Field'; import ObjectFieldHighlightedByPageSearch from '@features/transforms/search/ObjectFieldHighlightedByPageSearch'; import { ObjectData } from '@entities/types/DataTypes'; +import PinButton from '@shared/ui/PinButton'; + import TableColumn from './TableColumn'; const NAME_COLUMN_MAX_WIDTH = '20em'; +const TablePinCell: React.FC<{ object: ObjectData }> = ({ object }) => { + const { pinned, updatePageParams } = usePageParams(); + const isPinned = pinned.includes(object.ID); + const togglePin = useCallback(() => { + updatePageParams({ + pinned: isPinned ? pinned.filter((id) => id !== object.ID) : [...pinned, object.ID], + }); + }, [isPinned, pinned, object.ID, updatePageParams]); + + return ; +}; + +export const PinColumn: TableColumn = { + key: 'Pin', + label: '', + render: (object) => , + exportValue: () => '', +}; + export const CodeColumn: TableColumn = { key: 'ID', render: (object) => ( diff --git a/src/features/table/__tests__/InteractiveEntityTable.test.tsx b/src/features/table/__tests__/InteractiveEntityTable.test.tsx index e8811251a..d242a87d1 100644 --- a/src/features/table/__tests__/InteractiveEntityTable.test.tsx +++ b/src/features/table/__tests__/InteractiveEntityTable.test.tsx @@ -128,8 +128,9 @@ describe('InteractiveEntityTable', () => { ); }; - // Helper function to eliminate column header assertions - const expectColumnHeaders = (expectedCount = 2) => { + // Helper function to eliminate column header assertions. The count includes the + // always-present pin column that is prepended to every table. + const expectColumnHeaders = (expectedCount = 3) => { expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCount); expect(screen.getByRole('columnheader', { name: /Name/i })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: /Population/i })).toBeInTheDocument(); @@ -244,10 +245,10 @@ describe('InteractiveEntityTable', () => { // Force rerender to ensure state updates are applied rerenderEntityTable(rerender); - // Verify only Name column is visible + // Verify only Name column is visible (plus the always-present pin column) expect(screen.getByRole('columnheader', { name: /Name/i })).toBeInTheDocument(); expect(screen.queryByRole('columnheader', { name: /Population/i })).not.toBeInTheDocument(); - expect(screen.getAllByRole('columnheader')).toHaveLength(1); + expect(screen.getAllByRole('columnheader')).toHaveLength(2); // Click checkbox to show Population column again act(() => { diff --git a/src/features/table/tableStyles.css b/src/features/table/tableStyles.css index 6a02d5dc4..a0cea9d6b 100644 --- a/src/features/table/tableStyles.css +++ b/src/features/table/tableStyles.css @@ -29,3 +29,14 @@ summary:hover { summary:focus { outline: none; } + +/* The pin column button is only revealed when the row is hovered or the row is + pinned. The button's appearance lives in pinButton.css. */ +tbody tr .PinButton { + visibility: hidden; +} + +tbody tr:hover .PinButton, +tbody tr .PinButton.pinned { + visibility: visible; +} diff --git a/src/features/table/useColumnVisibility.tsx b/src/features/table/useColumnVisibility.tsx index fb5d8b45f..377856550 100644 --- a/src/features/table/useColumnVisibility.tsx +++ b/src/features/table/useColumnVisibility.tsx @@ -5,6 +5,7 @@ import usePageParams from '@features/params/usePageParams'; import { ObjectData } from '@entities/types/DataTypes'; +import { PinColumn } from './CommonColumns'; import TableColumn from './TableColumn'; import TableID from './TableID'; @@ -35,7 +36,10 @@ function useColumnVisibility( ); const visibleColumns = useMemo( - () => columns.filter((column) => columnVisibility[column.key]), + () => [ + PinColumn as TableColumn, + ...columns.filter((column) => columnVisibility[column.key]), + ], [columns, columnVisibility], ); diff --git a/src/widgets/cardlists/CardPinButton.tsx b/src/shared/ui/PinButton.tsx similarity index 51% rename from src/widgets/cardlists/CardPinButton.tsx rename to src/shared/ui/PinButton.tsx index 5221b35a6..277a1addf 100644 --- a/src/widgets/cardlists/CardPinButton.tsx +++ b/src/shared/ui/PinButton.tsx @@ -3,26 +3,29 @@ import React from 'react'; import HoverableButton from '@features/layers/hovercard/HoverableButton'; +import './pinButton.css'; + interface Props { + className?: string; isPinned: boolean; onTogglePin: () => void; } -const CardPinButton: React.FC = ({ isPinned, onTogglePin }) => { - // The action on a pinned card is always to unpin it, so the label reflects that. +const PinButton: React.FC = ({ className, isPinned, onTogglePin }) => { + // The action on a pinned item is always to unpin it, so the label reflects that. const label = isPinned ? 'Unpin from the page' : 'Pin to the page'; return ( - - + + ); }; -export default CardPinButton; +export default PinButton; diff --git a/src/shared/ui/pinButton.css b/src/shared/ui/pinButton.css new file mode 100644 index 000000000..369020ec4 --- /dev/null +++ b/src/shared/ui/pinButton.css @@ -0,0 +1,36 @@ +.PinButton { + padding: 0.25rem; + background: transparent; + border: none; + line-height: 0; + color: var(--color-text); +} + +/* Keep the icon button transparent even when focused/hovered. Without this the + global `button:focus`/`button:hover` rules paint a blue background that lingers + (e.g. after clicking) until focus moves elsewhere. */ +.PinButton:hover, +.PinButton:focus { + background: transparent; + border-color: transparent; + color: var(--color-text); +} + +.PinButton.pinned, +.PinButton.pinned:hover, +.PinButton.pinned:focus { + color: var(--color-button-primary); +} + +/* Show the "unpin" affordance only while hovering an already-pinned button. */ +.PinButton-iconUnpin { + display: none; +} + +.PinButton.pinned:hover .PinButton-iconPin { + display: none; +} + +.PinButton.pinned:hover .PinButton-iconUnpin { + display: inline-block; +} diff --git a/src/widgets/cardlists/CardInCardList.tsx b/src/widgets/cardlists/CardInCardList.tsx index f354e1838..d921c24f3 100644 --- a/src/widgets/cardlists/CardInCardList.tsx +++ b/src/widgets/cardlists/CardInCardList.tsx @@ -4,7 +4,7 @@ import usePageParams from '@features/params/usePageParams'; import { ObjectData } from '@entities/types/DataTypes'; -import CardPinButton from './CardPinButton'; +import PinButton from '@shared/ui/PinButton'; import './cardListStyles.css'; @@ -63,7 +63,7 @@ const CardInCardList: React.FC = ({ children, getBackgroundColor, object }} tabIndex={0} > - + {children} ); diff --git a/src/widgets/cardlists/cardListStyles.css b/src/widgets/cardlists/cardListStyles.css index 9c8053539..42919b0a2 100644 --- a/src/widgets/cardlists/cardListStyles.css +++ b/src/widgets/cardlists/cardListStyles.css @@ -17,49 +17,17 @@ border-color: var(--color-button-primary); } -.CardPinButton { +/* Position the shared pin button in the card's top-right corner. The button's + own appearance (colors, icon swap) lives in pinButton.css. */ +.CardInCardList .PinButton { position: absolute; top: 0.5rem; right: 0.5rem; - padding: 0.25rem; - background: transparent; - border: none; - line-height: 0; - color: var(--color-text); - /* Hidden until the card is hovered (or the card is pinned, see below). */ + /* Hidden until the card is hovered (or the item is pinned, see below). */ visibility: hidden; } -.CardInCardList:hover .CardPinButton, -.CardPinButton.pinned { +.CardInCardList:hover .PinButton, +.CardInCardList .PinButton.pinned { visibility: visible; } - -/* Keep the icon button transparent even when focused/hovered. Without this the - global `button:focus`/`button:hover` rules paint a blue background that lingers - (e.g. after clicking) until focus moves elsewhere. */ -.CardPinButton:hover, -.CardPinButton:focus { - background: transparent; - border-color: transparent; - color: var(--color-text); -} - -.CardPinButton.pinned, -.CardPinButton.pinned:hover, -.CardPinButton.pinned:focus { - color: var(--color-button-primary); -} - -/* Show the "unpin" affordance only while hovering an already-pinned button. */ -.CardPinButton-iconUnpin { - display: none; -} - -.CardPinButton.pinned:hover .CardPinButton-iconPin { - display: none; -} - -.CardPinButton.pinned:hover .CardPinButton-iconUnpin { - display: inline-block; -} From a3975937dac12b37f1a80d05c838379d9fb8d467 Mon Sep 17 00:00:00 2001 From: Akash Belide Date: Sun, 28 Jun 2026 15:01:36 -0400 Subject: [PATCH 2/2] Make first two table columns sticky and fix pin column export --- src/features/table/BaseEntityTable.tsx | 38 +++++++++++++------------- src/features/table/TableExport.tsx | 18 ++++++++++-- src/features/table/tableStyles.css | 27 ++++++++++++++++++ 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/features/table/BaseEntityTable.tsx b/src/features/table/BaseEntityTable.tsx index 4455b109c..32335c419 100644 --- a/src/features/table/BaseEntityTable.tsx +++ b/src/features/table/BaseEntityTable.tsx @@ -42,25 +42,25 @@ function BaseEntityTable({ visibleColumns, objects }: Prop {objects.map((object, i) => ( - {visibleColumns.map((column, idx) => ( - - - - ))} + {visibleColumns.map((column, idx) => { + const valueType = getValueTypeForColumn(column); + // The pin column (idx 0) and the first data column (idx 1) stay pinned to the + // left while scrolling horizontally. Their sticky positioning, background, and + // row-hover highlight live in tableStyles.css under `.alwaysVisible`. + const isSticky = idx <= 1; + return ( + + + + ); + })} ))} diff --git a/src/features/table/TableExport.tsx b/src/features/table/TableExport.tsx index 68f7c1a2c..10f1c9aae 100644 --- a/src/features/table/TableExport.tsx +++ b/src/features/table/TableExport.tsx @@ -14,6 +14,7 @@ import { trackEvent } from '@shared/lib/amplitude'; import { csvEscape, reactNodeToString } from '@shared/lib/stringExportUtils'; import LoadingIcon from '@shared/ui/LoadingIcon'; +import { PinColumn } from './CommonColumns'; import TableColumn from './TableColumn'; import { prepareUNESCODataForExport } from './UNESCOExport'; @@ -58,9 +59,22 @@ function TableExport({ visibleColumns, entities }: Props csvEscape(c.key)).join(separator); + // The pin column is always present in the table for the UI, but it carries no data on its + // own. Omit it entirely when nothing is pinned; otherwise export which rows are pinned. + const exportColumns = + pageParams.pinned.length > 0 + ? visibleColumns.map((c) => + c.key === PinColumn.key + ? { + ...c, + exportValue: (obj: T) => (pageParams.pinned.includes(obj.ID) ? 'Pinned' : ''), + } + : c, + ) + : visibleColumns.filter((c) => c.key !== PinColumn.key); + const header = exportColumns.map((c) => csvEscape(c.key)).join(separator); const rows = entities.map((obj) => { - return visibleColumns + return exportColumns .map(({ exportValue, render }) => { if (exportValue) return exportValue(obj); return reactNodeToString( diff --git a/src/features/table/tableStyles.css b/src/features/table/tableStyles.css index a0cea9d6b..53d56369c 100644 --- a/src/features/table/tableStyles.css +++ b/src/features/table/tableStyles.css @@ -40,3 +40,30 @@ tbody tr:hover .PinButton, tbody tr .PinButton.pinned { visibility: visible; } + +/* The first two body columns (the pin column and the first data column) stay + pinned to the left edge while the table is scrolled horizontally. */ +td.alwaysVisible { + /* Pin column width, reused as the left offset of the second sticky column. */ + --pin-column-width: 2.75em; + position: sticky; + background-color: var(--color-background); + z-index: 5; /* ZIndex.TableStickyColumn */ +} + +td.alwaysVisible:first-child { + left: 0; + box-sizing: border-box; + width: var(--pin-column-width); + min-width: var(--pin-column-width); +} + +td.alwaysVisible:nth-child(2) { + left: var(--pin-column-width); +} + +/* Sticky cells paint an opaque background that would otherwise hide the row + hover highlight, so re-apply the highlight to them while the row is hovered. */ +tbody tr:hover td.alwaysVisible { + background-color: var(--color-background-primary); +}