Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions src/features/table/BaseEntityTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,25 @@ function BaseEntityTable<T extends ObjectData>({ visibleColumns, objects }: Prop
<tbody>
{objects.map((object, i) => (
<tr key={object.ID || i}>
{visibleColumns.map((column, idx) => (
<td
key={column.key}
className={getValueTypeForColumn(column)}
style={{
maxWidth: MAX_COLUMN_WIDTH,
padding: '0.25em 0.5em',
position: idx === 0 ? 'sticky' : 'static',
left: idx === 0 ? 0 : 'auto',
backgroundColor: idx === 0 ? 'var(--color-background)' : 'inherit',
zIndex: idx === 0 ? ZIndex.TableStickyColumn : 'auto',
}}
>
<FormattedContent
content={column.render(object)}
valueType={getValueTypeForColumn(column)}
/>
</td>
))}
{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 (
<td
key={column.key}
className={isSticky ? `${valueType} alwaysVisible` : valueType}
style={{
maxWidth: MAX_COLUMN_WIDTH,
padding: '0.25em 0.5em',
}}
>
<FormattedContent content={column.render(object)} valueType={valueType} />
</td>
);
})}
</tr>
))}
</tbody>
Expand Down
24 changes: 24 additions & 0 deletions src/features/table/CommonColumns.tsx
Original file line number Diff line number Diff line change
@@ -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 <PinButton isPinned={isPinned} onTogglePin={togglePin} />;
};

export const PinColumn: TableColumn<ObjectData> = {
key: 'Pin',
label: '',
render: (object) => <TablePinCell object={object} />,
exportValue: () => '',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! I realize since it is always visible, it will be part of the table export. You should update TableExport to omit the "Pin" column when pinned is empty.

};

export const CodeColumn: TableColumn<ObjectData> = {
key: 'ID',
render: (object) => (
Expand Down
18 changes: 16 additions & 2 deletions src/features/table/TableExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -58,9 +59,22 @@ function TableExport<T extends ObjectData>({ visibleColumns, entities }: Props<T
if (exportType === ExportType.CopyCLDR) {
return prepareCLDRLocalePopulationForExport(entities);
}
const header = visibleColumns.map((c) => 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(() => {
Expand Down
38 changes: 38 additions & 0 deletions src/features/table/tableStyles.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,41 @@ 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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw BaseEntityTable has styling for the first column that makes the ID column sticky so if you see lots of columns and scroll to the right the ID column stays. However that rule is for idx === 0 but now that is the Pin column. Can you update it so its idx <= 1?

While you are there, you can also fix a fun. When I added it, I did not think about conflicting styles so the background row highlight doesn't work for this column. So I would do something similar to before when we add a new value (eg. alwaysVisible) to className if idx <= 1 and do the backgroundColor and etc in this css file.


/* 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);
}
6 changes: 5 additions & 1 deletion src/features/table/useColumnVisibility.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -35,7 +36,10 @@ function useColumnVisibility<T extends ObjectData>(
);

const visibleColumns = useMemo(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perfect

() => columns.filter((column) => columnVisibility[column.key]),
() => [
PinColumn as TableColumn<T>,
...columns.filter((column) => columnVisibility[column.key]),
],
[columns, columnVisibility],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = ({ isPinned, onTogglePin }) => {
// The action on a pinned card is always to unpin it, so the label reflects that.
const PinButton: React.FC<Props> = ({ className, isPinned, onTogglePin }) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great idea to generalize the pin button :)

// 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 (
<HoverableButton
ariaLabel={label}
className={'CardPinButton' + (isPinned ? ' pinned' : '')}
className={'PinButton' + (isPinned ? ' pinned' : '') + (className ? ' ' + className : '')}
hoverContent={label}
onClick={onTogglePin}
>
<PinIcon className="CardPinButton-iconPin" size="1em" />
<PinOffIcon className="CardPinButton-iconUnpin" size="1em" />
<PinIcon className="PinButton-iconPin" size="1em" />
<PinOffIcon className="PinButton-iconUnpin" size="1em" />
</HoverableButton>
);
};

export default CardPinButton;
export default PinButton;
36 changes: 36 additions & 0 deletions src/shared/ui/pinButton.css
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions src/widgets/cardlists/CardInCardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,7 +63,7 @@ const CardInCardList: React.FC<Props> = ({ children, getBackgroundColor, object
}}
tabIndex={0}
>
<CardPinButton isPinned={isPinned} onTogglePin={togglePin} />
<PinButton isPinned={isPinned} onTogglePin={togglePin} />
{children}
</div>
);
Expand Down
44 changes: 6 additions & 38 deletions src/widgets/cardlists/cardListStyles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great!

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;
}
Loading