Refactor table header with divs (#14319)

Fixes https://github.com/twentyhq/core-team-issues/issues/1432

We still have some follow-up issues here, I only took care of making the
essential of the table work, but the follow-up issues will be better
tackled after everything has been switched to div, otherwise we might
fix them multiple times.

A first round of refactoring of z-index has been made in a common
constant, where it is easier to understand, for which case, what should
be the order of the different layers.

Though we still need to have another round of refactor because of the
stacking contexts, mainly because of the header row that is creating a
stacking context that makes it hard to have all scrolling cases work.
This commit is contained in:
Lucas Bordeau
2025-09-05 18:54:38 +02:00
committed by GitHub
parent e8511a4da0
commit 821ae7c60d
25 changed files with 426 additions and 345 deletions
@@ -1,7 +1,5 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { useChipFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useChipFieldDisplay';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
import { ChipSize } from 'twenty-ui/components';
import { useIsMobile } from 'twenty-ui/utilities';
@@ -18,12 +16,9 @@ export const ChipFieldDisplay = () => {
} = useChipFieldDisplay();
const isMobile = useIsMobile();
const isRecordTableScrolledLeftComponent = useRecoilComponentValue(
isRecordTableScrolledLeftComponentState,
);
const isLabelIdentifierCompact =
isMobile && !isRecordTableScrolledLeftComponent;
// TODO: reimplement scrolled horizontally here.
const isLabelIdentifierCompact = isMobile;
if (!isDefined(recordValue)) {
return null;
@@ -1,6 +1,6 @@
import { RecordTableStickyBottomEffect } from '@/object-record/record-table/components/RecordTableStickyBottomEffect';
import { RecordTableStickyEffect } from '@/object-record/record-table/components/RecordTableStickyEffect';
import { StyledTable } from '@/object-record/record-table/components/RecordTableStyles';
import { StyledTableDiv } from '@/object-record/record-table/components/RecordTableStyles';
import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody';
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
@@ -12,7 +12,7 @@ import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { useRecoilCallback } from 'recoil';
const StyledTableWithPointerEvents = styled(StyledTable)<{
const StyledTableWithPointerEvents = styled(StyledTableDiv)<{
isDragging: boolean;
}>`
& > * {
@@ -1,4 +1,4 @@
import { StyledTable } from '@/object-record/record-table/components/RecordTableStyles';
import { StyledTableDiv } from '@/object-record/record-table/components/RecordTableStyles';
import { RecordTableEmptyState } from '@/object-record/record-table/empty-state/components/RecordTableEmptyState';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import styled from '@emotion/styled';
@@ -14,9 +14,9 @@ export interface RecordTableEmptyProps {
export const RecordTableEmpty = ({ tableBodyRef }: RecordTableEmptyProps) => (
<StyledEmptyStateContainer>
<StyledTable ref={tableBodyRef}>
<StyledTableDiv ref={tableBodyRef}>
<RecordTableHeader />
</StyledTable>
</StyledTableDiv>
<RecordTableEmptyState />
</StyledEmptyStateContainer>
);
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState';
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
import { scrollWrapperScrollLeftComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollLeftComponentState';
import { scrollWrapperScrollTopComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollTopComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -11,7 +12,13 @@ export const RecordTableStickyEffect = () => {
scrollWrapperScrollTopComponentState,
);
const setIsRecordTableScrolledVertically = useSetRecoilComponentState(
isRecordTableScrolledVerticallyComponentState,
);
useEffect(() => {
setIsRecordTableScrolledVertically(scrollTop > 0);
if (scrollTop > 0) {
document
.getElementById('record-table-header')
@@ -21,18 +28,19 @@ export const RecordTableStickyEffect = () => {
.getElementById('record-table-header')
?.classList.remove('header-sticky');
}
}, [scrollTop]);
}, [scrollTop, setIsRecordTableScrolledVertically]);
const scrollLeft = useRecoilComponentValue(
scrollWrapperScrollLeftComponentState,
);
const setIsRecordTableScrolledLeft = useSetRecoilComponentState(
isRecordTableScrolledLeftComponentState,
const setIsRecordTableScrolledHorizontally = useSetRecoilComponentState(
isRecordTableScrolledHorizontallyComponentState,
);
useEffect(() => {
setIsRecordTableScrolledLeft(scrollLeft === 0);
setIsRecordTableScrolledHorizontally(scrollLeft > 0);
if (scrollLeft > 0) {
document
.getElementById('record-table-body')
@@ -54,7 +62,7 @@ export const RecordTableStickyEffect = () => {
.getElementById('record-table-footer')
?.classList.remove('first-columns-sticky');
}
}, [scrollLeft, setIsRecordTableScrolledLeft]);
}, [scrollLeft, setIsRecordTableScrolledHorizontally]);
return <></>;
};
@@ -1,9 +1,8 @@
import styled from '@emotion/styled';
export const StyledTable = styled.table`
export const StyledTableDiv = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
border-spacing: 0;
table-layout: fixed;
width: 100%;
.footer-sticky tr:nth-last-of-type(2) td {
@@ -17,7 +17,7 @@ const StyledTableContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
width: fit-content;
height: 100%;
`;
@@ -1,17 +1,37 @@
export const TABLE_Z_INDEX = {
base: 1,
cell: {
default: 1,
sticky: 4,
editMode: 6,
default: 3,
sticky: 10,
editMode: 20,
},
footer: {
default: 4,
stickyColumn: 5,
default: 12,
stickyColumn: 20,
},
header: {
default: 6,
stickyColumn: 10,
noScrollAtAll: {
hoverPortalCell: 17,
headerRow: 13,
headerColumnsSticky: 16,
headerColumnsNormal: 15,
},
scrolledBothVerticallyAndHorizontally: {
headerColumnsSticky: 13,
headerColumnsNormal: 12,
headerRow: 11,
hoverPortalCell: 2,
},
scrolledHorizontallyOnly: {
headerColumnsSticky: 13,
headerColumnsNormal: 12,
headerRow: 11,
hoverPortalCell: 9,
},
scrolledVerticallyOnly: {
headerColumnsSticky: 14,
headerColumnsNormal: 13,
headerRow: 12,
hoverPortalCell: 11,
},
columnGrip: 30,
};
@@ -1,38 +1,32 @@
import styled from '@emotion/styled';
import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
const StyledTbody = styled.tbody`
&.first-columns-sticky {
td:nth-of-type(1) {
position: sticky;
left: 0;
z-index: ${TABLE_Z_INDEX.cell.sticky};
transition: transform 0.3s ease;
}
td:nth-of-type(2) {
position: sticky;
left: 11px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
transition: transform 0.3s ease;
}
tr:not(:last-child) td:nth-of-type(3) {
// Last row is aggregate footer
position: sticky;
left: 39px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
transition: transform 0.3s ease;
// TODO: re-implement horizontal scroll here after table have been refactored to divs
td:nth-of-type(1) {
position: sticky;
left: 0px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
}
&:not(.disable-shadow)::after {
content: '';
position: absolute;
top: -1px;
height: calc(100% + 2px);
width: 4px;
right: 0px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
clip-path: inset(0px -4px 0px 0px);
}
td:nth-of-type(2) {
position: sticky;
left: 16px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
}
tr:not(:last-child) td:nth-of-type(3) {
position: sticky;
left: 49px;
z-index: ${TABLE_Z_INDEX.cell.sticky};
}
td:nth-of-type(3) {
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: ${38}px;
max-width: ${38}px;
}
}
`;
@@ -2,10 +2,19 @@ import { RecordTableBody } from '@/object-record/record-table/record-table-body/
import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext';
import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import styled from '@emotion/styled';
import { Droppable } from '@hello-pangea/dnd';
import { type ReactNode, useState } from 'react';
import { v4 } from 'uuid';
const StyledTable = styled.table`
table-layout: fixed;
border-radius: ${({ theme }) => theme.border.radius.sm};
border-spacing: 0;
width: 100%;
`;
type RecordTableBodyDroppableProps = {
children: ReactNode;
recordGroupId?: string;
@@ -30,19 +39,21 @@ export const RecordTableBodyDroppable = ({
isDropDisabled={isDropDisabled}
>
{(provided) => (
<RecordTableBody
id={recordTableBodyId}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
onMouseLeave={() => setRecordTableHoverPosition(null)}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
<StyledTable>
<RecordTableBody
id={recordTableBodyId}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
onMouseLeave={() => setRecordTableHoverPosition(null)}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
</StyledTable>
)}
</Droppable>
);
@@ -5,7 +5,7 @@ import { useRecordTableRowDraggableContextOrThrow } from '@/object-record/record
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { IconListViewGrip } from 'twenty-ui/input';
export const TABLE_CELL_GRIP_WIDTH = '16px';
export const TABLE_CELL_GRIP_WIDTH = 16;
const StyledContainer = styled.div`
height: 32px;
@@ -40,6 +40,7 @@ export const RecordTableCellGrip = () => {
data-select-disable
hasRightBorder={false}
hasBottomBorder={false}
width={TABLE_CELL_GRIP_WIDTH}
>
<StyledContainer>
<StyledIconWrapper className="icon" isDragging={isDragging}>
@@ -6,12 +6,15 @@ import styled from '@emotion/styled';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly';
import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex';
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableCellDisplayMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode';
import { RecordTableCellEditButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditButton';
import { RecordTableCellEditMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditMode';
import { RecordTableCellFieldInput } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState';
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useContext } from 'react';
import { BORDER_COMMON } from 'twenty-ui/theme';
@@ -20,6 +23,7 @@ import { useIsMobile } from 'twenty-ui/utilities';
const StyledRecordTableCellHoveredPortalContent = styled.div<{
isReadOnly: boolean;
isRowActive: boolean;
zIndex: number;
}>`
align-items: center;
background: ${({ theme }) => theme.background.transparent.secondary};
@@ -42,6 +46,8 @@ const StyledRecordTableCellHoveredPortalContent = styled.div<{
position: relative;
user-select: none;
z-index: ${({ zIndex }) => zIndex};
`;
const RecordTableCellHoveredPortalContent = () => {
@@ -67,10 +73,28 @@ const RecordTableCellHoveredPortalContent = () => {
rowIndex,
);
const isRecordTableScrolledHorizontally = useRecoilComponentValue(
isRecordTableScrolledHorizontallyComponentState,
);
const isRecordTableScrolledVertically = useRecoilComponentValue(
isRecordTableScrolledVerticallyComponentState,
);
const computedZIndex =
isRecordTableScrolledHorizontally && isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.hoverPortalCell
: isRecordTableScrolledHorizontally
? TABLE_Z_INDEX.scrolledHorizontallyOnly.hoverPortalCell
: isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledVerticallyOnly.hoverPortalCell
: TABLE_Z_INDEX.noScrollAtAll.hoverPortalCell;
return (
<StyledRecordTableCellHoveredPortalContent
isReadOnly={isReadOnly}
isRowActive={isRowActive}
zIndex={computedZIndex}
>
{isFieldInputOnly ? (
<RecordTableCellEditMode>
@@ -1,22 +1,23 @@
import { type DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { type ReactNode, useContext } from 'react';
import { MOBILE_VIEWPORT, ThemeContext } from 'twenty-ui/theme';
import { ThemeContext } from 'twenty-ui/theme';
export const RECORD_TABLE_TD_WIDTH = '32px';
export const DEFAULT_RECORD_TABLE_TD_WIDTH = 32;
const StyledTd = styled.td<{
backgroundColor: string;
borderColor: string;
isDragging?: boolean;
fontColor: string;
sticky?: boolean;
freezeFirstColumns?: boolean;
left?: number;
hasRightBorder?: boolean;
hasBottomBorder?: boolean;
width?: number;
width: number;
}>`
min-width: ${({ width }) => width}px;
width: ${({ width }) => width}px;
max-width: ${({ width }) => width}px;
border-bottom: 1px solid
${({ borderColor, hasBottomBorder, isDragging }) =>
hasBottomBorder && !isDragging ? borderColor : 'transparent'};
@@ -26,46 +27,31 @@ const StyledTd = styled.td<{
hasRightBorder && !isDragging ? `1px solid ${borderColor}` : 'none'};
padding: 0;
transition: transform 0.3s ease;
text-align: left;
background: ${({ backgroundColor, isDragging }) =>
isDragging ? 'transparent' : backgroundColor};
${({ freezeFirstColumns }) =>
freezeFirstColumns
? `@media (max-width: ${MOBILE_VIEWPORT}px) {
width: ${RECORD_TABLE_TD_WIDTH};
max-width: ${RECORD_TABLE_TD_WIDTH};
}`
: ''}
// TODO: reimplement horizontal scroll here once we have refactored body with divs
`;
export const RecordTableTd = ({
children,
isSelected,
isDragging,
sticky,
freezeFirstColumns,
left,
hasRightBorder = true,
hasBottomBorder = true,
width,
colSpan,
width = DEFAULT_RECORD_TABLE_TD_WIDTH,
...dragHandleProps
}: {
className?: string;
children?: ReactNode;
isSelected?: boolean;
isDragging?: boolean;
sticky?: boolean;
freezeFirstColumns?: boolean;
hasRightBorder?: boolean;
hasBottomBorder?: boolean;
left?: number;
width?: number;
colSpan?: number;
} & (Partial<DraggableProvidedDragHandleProps> | null)) => {
const { theme } = useContext(ThemeContext);
@@ -83,13 +69,9 @@ export const RecordTableTd = ({
backgroundColor={tdBackgroundColor}
borderColor={borderColor}
fontColor={fontColor}
sticky={sticky}
freezeFirstColumns={freezeFirstColumns}
left={left}
hasRightBorder={hasRightBorder}
hasBottomBorder={hasBottomBorder}
width={width}
colSpan={colSpan}
// eslint-disable-next-line react/jsx-props-no-spreading
{...dragHandleProps}
>
@@ -20,44 +20,45 @@ const StyledTableRow = styled.tr<{
position: sticky;
border: none;
&.footer-sticky {
td {
border-top: ${({ theme }) => `1px solid ${theme.border.color.light}`};
z-index: ${TABLE_Z_INDEX.footer.default};
position: sticky;
bottom: 0;
}
// TODO: see how we reimplement horizontal scrolling after all table has been refactored
td {
border-top: ${({ theme }) => `1px solid ${theme.border.color.light}`};
z-index: ${TABLE_Z_INDEX.footer.default};
position: sticky;
bottom: 0;
}
cursor: pointer;
td:nth-of-type(1) {
width: ${FIRST_TH_WIDTH};
left: 0;
border-top: none;
}
&.first-columns-sticky {
td:nth-of-type(1) {
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
td:nth-of-type(1) {
position: sticky;
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
}
td:nth-of-type(2) {
position: sticky;
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
transition: 0.3s ease;
&::after {
content: '';
position: absolute;
top: -1px;
height: calc(100% + 2px);
width: 4px;
right: 0px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
clip-path: inset(0px -4px 0px 0px);
}
td:nth-of-type(2) {
position: sticky;
z-index: ${TABLE_Z_INDEX.footer.stickyColumn};
transition: 0.3s ease;
&::after {
content: '';
position: absolute;
top: -1px;
height: calc(100% + 2px);
width: 4px;
right: 0px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
clip-path: inset(0px -4px 0px 0px);
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 34px;
max-width: 34px;
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 38px;
max-width: 38px;
}
}
background: ${({ theme }) => theme.background.primary};
${({ hasHorizontalOverflow }) =>
`.footer-sticky {
@@ -1,7 +1,7 @@
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState';
import { fieldMetadataItemByIdSelector } from '@/object-metadata/states/fieldMetadataItemByIdSelector';
import { isFieldMetadataItemLabelIdentifierSelector } from '@/object-metadata/states/isFieldMetadataItemLabelIdentifierSelector';
@@ -61,8 +61,8 @@ export const RecordTableColumnHead = ({
}),
);
const isRecordTableScrolledLeft = useRecoilComponentValue(
isRecordTableScrolledLeftComponentState,
const isRecordTableScrolledHorizontally = useRecoilComponentValue(
isRecordTableScrolledHorizontallyComponentState,
);
const { getIcon } = useIcons();
@@ -77,7 +77,9 @@ export const RecordTableColumnHead = ({
);
return (
<StyledTitle hideTitle={isLabelIdentifier && !isRecordTableScrolledLeft}>
<StyledTitle
hideTitle={isLabelIdentifier && isRecordTableScrolledHorizontally}
>
<StyledIcon>
<Icon size={theme.icon.size.md} />
</StyledIcon>
@@ -2,99 +2,140 @@ import styled from '@emotion/styled';
import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableHeaderAddColumnButton } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderAddColumnButton';
import { RecordTableHeaderCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCell';
import { RecordTableHeaderCheckboxColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn';
import { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn';
import { RecordTableHeaderLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastColumn';
import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState';
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
export const FIRST_TH_WIDTH = '10px';
const StyledTableHead = styled.thead`
const StyledTableHead = styled.div<{
stickyColumnZIndex: number;
normalColumnZIndex: number;
entireRowZIndex: number;
}>`
cursor: pointer;
th:nth-of-type(1) {
width: ${FIRST_TH_WIDTH};
left: 0;
border-right-color: ${({ theme }) => theme.background.primary};
display: flex;
flex-direction: row;
align-items: center;
height: 32px;
background-color: ${({ theme }) => theme.background.primary};
div.header-cell:nth-of-type(n + 3) {
z-index: ${({ normalColumnZIndex }) => normalColumnZIndex};
}
th:nth-of-type(2) {
border-right-color: ${({ theme }) => theme.background.primary};
div.header-cell:nth-of-type(1) {
position: sticky;
left: 0px;
z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex};
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
}
&.first-columns-sticky {
th:nth-of-type(1) {
position: sticky;
left: 0;
z-index: ${TABLE_Z_INDEX.header.default};
transition: 0.3s ease;
}
div.header-cell:nth-of-type(2) {
position: sticky;
left: 17px;
top: 0;
z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex};
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
}
th:nth-of-type(2) {
position: sticky;
left: 11px;
z-index: ${TABLE_Z_INDEX.header.default};
transition: 0.3s ease;
}
div.header-cell:nth-of-type(3) {
position: sticky;
left: 49px;
right: 0;
z-index: ${({ stickyColumnZIndex }) => stickyColumnZIndex};
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
th:nth-of-type(3) {
position: sticky;
left: 39px;
z-index: ${TABLE_Z_INDEX.header.default};
transition: 0.3s ease;
// &::after {
// content: '';
// position: absolute;
// top: -1px;
// height: calc(100% + 2px);
// width: 4px;
// right: 0px;
// box-shadow: ${({ theme }) => theme.boxShadow.light};
// clip-path: inset(0px -4px 0px 0px);
// }
&::after {
content: '';
position: absolute;
top: -1px;
height: calc(100% + 2px);
width: 4px;
right: 0px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
clip-path: inset(0px -4px 0px 0px);
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 34px;
max-width: 34px;
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 38px;
max-width: 38px;
min-width: 38px;
}
}
&.header-sticky {
th {
position: sticky;
top: 0;
z-index: ${TABLE_Z_INDEX.header.default};
}
}
&.header-sticky.first-columns-sticky {
th:nth-of-type(1),
th:nth-of-type(2),
th:nth-of-type(3) {
z-index: ${TABLE_Z_INDEX.header.stickyColumn};
}
}
position: sticky;
top: 0px;
z-index: ${({ entireRowZIndex }) => entireRowZIndex};
`;
export const RecordTableHeader = () => {
const { visibleRecordFields } = useRecordTableContextOrThrow();
const isRecordTableScrolledHorizontally = useRecoilComponentValue(
isRecordTableScrolledHorizontallyComponentState,
);
const isRecordTableScrolledVertically = useRecoilComponentValue(
isRecordTableScrolledVerticallyComponentState,
);
const computedStickyColumnZIndex =
isRecordTableScrolledHorizontally && isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.headerColumnsSticky
: isRecordTableScrolledHorizontally
? TABLE_Z_INDEX.scrolledHorizontallyOnly.headerColumnsSticky
: isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledVerticallyOnly.headerColumnsSticky
: TABLE_Z_INDEX.noScrollAtAll.headerColumnsSticky;
const computedNormalColumnZIndex =
isRecordTableScrolledHorizontally && isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.headerColumnsNormal
: isRecordTableScrolledHorizontally
? TABLE_Z_INDEX.scrolledHorizontallyOnly.headerColumnsNormal
: isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledVerticallyOnly.headerColumnsNormal
: TABLE_Z_INDEX.noScrollAtAll.headerColumnsNormal;
const computedHeaderRowZIndex =
isRecordTableScrolledHorizontally && isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledBothVerticallyAndHorizontally.headerRow
: isRecordTableScrolledHorizontally
? TABLE_Z_INDEX.scrolledHorizontallyOnly.headerRow
: isRecordTableScrolledVertically
? TABLE_Z_INDEX.scrolledVerticallyOnly.headerRow
: TABLE_Z_INDEX.noScrollAtAll.headerRow;
return (
<StyledTableHead id="record-table-header" data-select-disable>
<tr>
<RecordTableHeaderDragDropColumn />
<RecordTableHeaderCheckboxColumn />
{visibleRecordFields.map((recordField) => (
<RecordTableHeaderCell
key={recordField.fieldMetadataItemId}
recordField={recordField}
/>
))}
<RecordTableHeaderLastColumn />
</tr>
<StyledTableHead
id="record-table-header"
data-select-disable
stickyColumnZIndex={computedStickyColumnZIndex}
normalColumnZIndex={computedNormalColumnZIndex}
entireRowZIndex={computedHeaderRowZIndex}
>
<RecordTableHeaderDragDropColumn />
<RecordTableHeaderCheckboxColumn />
{visibleRecordFields.map((recordField) => (
<RecordTableHeaderCell
key={recordField.fieldMetadataItemId}
recordField={recordField}
/>
))}
<RecordTableHeaderAddColumnButton />
<RecordTableHeaderLastColumn />
</StyledTableHead>
);
};
@@ -0,0 +1,90 @@
import styled from '@emotion/styled';
import { HIDDEN_TABLE_COLUMN_DROPDOWN_ID } from '@/object-record/record-table/constants/HiddenTableColumnDropdownId';
import { RecordTableHeaderPlusButtonContent } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useTheme } from '@emotion/react';
import { IconPlus } from 'twenty-ui/display';
const StyledPlusIconHeaderCell = styled.div<{
isTableWiderThanScreen: boolean;
isFirstRowActiveOrFocused: boolean;
}>`
border-bottom: ${({ isFirstRowActiveOrFocused, theme }) =>
isFirstRowActiveOrFocused
? 'none'
: `1px solid ${theme.border.color.light}`};
background-color: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.tertiary};
border-right: ${({ theme }) => theme.border.color.light} !important;
cursor: default;
width: 32px;
z-index: 1;
&:hover {
background: ${({ theme }) => theme.background.transparent.secondary};
}
`;
const StyledPlusIconContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
width: 100%;
justify-content: center;
`;
const StyledDropdownContainer = styled.div`
cursor: pointer;
width: 100%;
`;
export const RecordTableHeaderAddColumnButton = () => {
const theme = useTheme();
const { scrollWrapperHTMLElement } = useScrollWrapperElement();
const isTableWiderThanScreen =
(scrollWrapperHTMLElement?.clientWidth ?? 0) <
(scrollWrapperHTMLElement?.scrollWidth ?? 0);
const isFirstRowActive = useRecoilComponentFamilyValue(
isRecordTableRowActiveComponentFamilyState,
0,
);
const isFirstRowFocused = useRecoilComponentFamilyValue(
isRecordTableRowFocusedComponentFamilyState,
0,
);
const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused;
return (
<StyledPlusIconHeaderCell
isTableWiderThanScreen={isTableWiderThanScreen}
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
>
<StyledDropdownContainer>
<Dropdown
dropdownId={HIDDEN_TABLE_COLUMN_DROPDOWN_ID}
clickableComponent={
<StyledPlusIconContainer>
<IconPlus size={theme.icon.size.md} />
</StyledPlusIconContainer>
}
dropdownComponents={<RecordTableHeaderPlusButtonContent />}
dropdownPlacement="bottom-start"
/>
</StyledDropdownContainer>
</StyledPlusIconHeaderCell>
);
};
@@ -12,7 +12,6 @@ import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useC
import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { type PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
@@ -20,7 +19,6 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useSaveRecordFields } from '@/views/hooks/useSaveRecordFields';
import { throwIfNotDefined } from 'twenty-shared/utils';
@@ -29,26 +27,24 @@ import { LightIconButton } from 'twenty-ui/input';
const COLUMN_MIN_WIDTH = 104;
const StyledColumnHeaderCell = styled.th<{
const StyledColumnHeaderCell = styled.div<{
columnWidth: number;
isResizing?: boolean;
isFirstRowActiveOrFocused: boolean;
}>`
border-bottom: ${({ isFirstRowActiveOrFocused, theme }) =>
isFirstRowActiveOrFocused
? 'none'
: `1px solid ${theme.border.color.light}`};
color: ${({ theme }) => theme.font.color.tertiary};
padding: 0;
text-align: left;
background-color: ${({ theme }) => theme.background.primary};
border-right: 1px solid ${({ theme }) => theme.border.color.light};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
${({ columnWidth }) => `
min-width: ${columnWidth}px;
width: ${columnWidth}px;
`}
position: relative;
user-select: none;
${({ theme }) => {
return `
@@ -96,8 +92,6 @@ const StyledColumnHeadContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
position: relative;
z-index: 1;
& > :first-of-type {
flex: 1;
@@ -202,10 +196,6 @@ export const RecordTableHeaderCell = ({
onMouseUp: handleResizeHandlerEnd,
});
const isRecordTableScrolledLeft = useRecoilComponentValue(
isRecordTableScrolledLeftComponentState,
);
const isMobile = useIsMobile();
const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
@@ -213,8 +203,7 @@ export const RecordTableHeaderCell = ({
const isLabelIdentifier =
recordField.fieldMetadataItemId === labelIdentifierFieldMetadataItem?.id;
const disableColumnResize =
isLabelIdentifier && isMobile && !isRecordTableScrolledLeft;
const disableColumnResize = isLabelIdentifier || isMobile;
const { createNewIndexRecord } = useCreateNewIndexRecord({
objectMetadataItem,
@@ -241,22 +230,27 @@ export const RecordTableHeaderCell = ({
0,
);
const widthOffsetWhileResizing =
resizedFieldMetadataItemId === recordField.fieldMetadataItemId
? resizeFieldOffset
: 0;
const baseWidth = recordField?.size ?? 0;
const computedDynamicWidth = baseWidth + widthOffsetWhileResizing;
const columnWidth = Math.max(computedDynamicWidth, COLUMN_MIN_WIDTH);
const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused;
return (
<StyledColumnHeaderCell
className="header-cell"
key={recordField.fieldMetadataItemId}
isResizing={
resizedFieldMetadataItemId === recordField.fieldMetadataItemId
}
columnWidth={Math.max(
(recordField?.size ?? 0) +
(resizedFieldMetadataItemId === recordField.fieldMetadataItemId
? resizeFieldOffset
: 0) +
24,
COLUMN_MIN_WIDTH,
)}
columnWidth={columnWidth}
onMouseEnter={() => setIconVisibility(true)}
onMouseLeave={() => setIconVisibility(false)}
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
@@ -17,21 +17,20 @@ const StyledContainer = styled.div`
display: flex;
height: 32px;
justify-content: center;
width: 24px;
min-width: 24px;
padding-right: ${({ theme }) => theme.spacing(1)};
background-color: ${({ theme }) => theme.background.primary};
`;
const StyledColumnHeaderCell = styled.th<{
const StyledColumnHeaderCell = styled.div<{
isFirstRowActiveOrFocused: boolean;
}>`
background-color: ${({ theme }) => theme.background.primary};
border-bottom: ${({ isFirstRowActiveOrFocused, theme }) =>
isFirstRowActiveOrFocused
? 'none'
: `1px solid ${theme.border.color.light}`};
width: 28px;
min-width: 32px;
box-sizing: border-box;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
export const RecordTableHeaderCheckboxColumn = () => {
@@ -85,6 +84,7 @@ export const RecordTableHeaderCheckboxColumn = () => {
return (
<StyledColumnHeaderCell
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
className="header-cell"
>
<StyledContainer data-select-disable>
<Checkbox
@@ -2,14 +2,21 @@ import { styled } from '@linaria/react';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme';
const StyledTh = styled.th<{ backgroundColor: string }>`
background: ${({ backgroundColor }) => backgroundColor};
border-bottom: none;
border-top: none;
const StyledTh = styled.div<{ backgroundColor: string }>`
background-color: ${({ backgroundColor }) => backgroundColor};
min-width: 17px;
min-height: 100%;
border-bottom: 1px solid ${({ backgroundColor }) => backgroundColor};
`;
export const RecordTableHeaderDragDropColumn = () => {
const { theme } = useContext(ThemeContext);
return <StyledTh backgroundColor={theme.background.primary}></StyledTh>;
return (
<StyledTh
className="header-cell"
backgroundColor={theme.background.primary}
></StyledTh>
);
};
@@ -1,93 +1,16 @@
import styled from '@emotion/styled';
import { HIDDEN_TABLE_COLUMN_DROPDOWN_ID } from '@/object-record/record-table/constants/HiddenTableColumnDropdownId';
import { RecordTableHeaderPlusButtonContent } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useTheme } from '@emotion/react';
import { IconPlus } from 'twenty-ui/display';
const StyledLastColumnHeader = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
const StyledPlusIconHeaderCell = styled.th<{
isTableWiderThanScreen: boolean;
isFirstRowActiveOrFocused: boolean;
}>`
border-bottom: ${({ isFirstRowActiveOrFocused, theme }) =>
isFirstRowActiveOrFocused
? 'none'
: `1px solid ${theme.border.color.light}`};
background-color: ${({ theme }) => theme.background.primary};
border-left: none !important;
color: ${({ theme }) => theme.font.color.tertiary};
border-right: none !important;
cursor: default;
${({ isTableWiderThanScreen, theme }) =>
isTableWiderThanScreen
? `
background-color: ${theme.background.primary};
width: 32px;
`
: 'width: 100%'};
z-index: 1;
&:hover {
background: ${({ theme }) => theme.background.transparent.secondary};
}
`;
const StyledPlusIconContainer = styled.div`
align-items: center;
display: flex;
width: 100%;
height: 32px;
justify-content: flex-start;
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledDropdownContainer = styled.div`
cursor: pointer;
`;
export const RecordTableHeaderLastColumn = () => {
const theme = useTheme();
const { scrollWrapperHTMLElement } = useScrollWrapperElement();
const isTableWiderThanScreen =
(scrollWrapperHTMLElement?.clientWidth ?? 0) <
(scrollWrapperHTMLElement?.scrollWidth ?? 0);
const isFirstRowActive = useRecoilComponentFamilyValue(
isRecordTableRowActiveComponentFamilyState,
0,
);
const isFirstRowFocused = useRecoilComponentFamilyValue(
isRecordTableRowFocusedComponentFamilyState,
0,
);
const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused;
return (
<StyledPlusIconHeaderCell
isTableWiderThanScreen={isTableWiderThanScreen}
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
>
<StyledDropdownContainer>
<Dropdown
dropdownId={HIDDEN_TABLE_COLUMN_DROPDOWN_ID}
clickableComponent={
<StyledPlusIconContainer>
<IconPlus size={theme.icon.size.md} />
</StyledPlusIconContainer>
}
dropdownComponents={<RecordTableHeaderPlusButtonContent />}
dropdownPlacement="bottom-start"
/>
</StyledDropdownContainer>
</StyledPlusIconHeaderCell>
);
return <StyledLastColumnHeader></StyledLastColumnHeader>;
};
@@ -11,6 +11,7 @@ export const RecordTableCellsEmpty = () => {
<RecordTableTd
isSelected={isSelected}
key={recordField.fieldMetadataItemId}
width={recordField.size}
/>
));
};
@@ -19,8 +19,9 @@ const StyledTr = styled.tr<{
isDragging
? `1px solid ${theme.border.color.medium}`
: '1px solid transparent'};
border-left: none;
position: relative;
transition: border-left-color 0.2s ease-in-out;
&[data-next-row-active-or-focused='true'] {
td {
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState';
import { scrollWrapperScrollLeftComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollLeftComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
@@ -11,38 +11,16 @@ export const RecordTableRecordGroupStickyEffect = () => {
scrollWrapperScrollLeftComponentState,
);
const setIsRecordTableScrolledLeft = useSetRecoilComponentState(
isRecordTableScrolledLeftComponentState,
const setIsRecordTableScrolledHorizontally = useSetRecoilComponentState(
isRecordTableScrolledHorizontallyComponentState,
);
const currentRecordGroupId = useCurrentRecordGroupId();
useEffect(() => {
setIsRecordTableScrolledLeft(scrollLeft === 0);
if (scrollLeft > 0) {
document
.getElementById(
`record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.add('first-columns-sticky');
document
.getElementById(
`record-table-body${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.add('first-columns-sticky');
} else {
document
.getElementById(
`record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.remove('first-columns-sticky');
document
.getElementById(
`record-table-body${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.remove('first-columns-sticky');
}
}, [currentRecordGroupId, scrollLeft, setIsRecordTableScrolledLeft]);
setIsRecordTableScrolledHorizontally(scrollLeft > 0);
// TODO: see if we need to reimplement setting classes here.
}, [currentRecordGroupId, scrollLeft, setIsRecordTableScrolledHorizontally]);
return <></>;
};
@@ -0,0 +1,9 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const isRecordTableScrolledHorizontallyComponentState =
createComponentState<boolean>({
key: 'isRecordTableScrolledHorizontallyComponentState',
componentInstanceContext: RecordTableComponentInstanceContext,
defaultValue: true,
});
@@ -1,9 +1,9 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const isRecordTableScrolledLeftComponentState =
export const isRecordTableScrolledVerticallyComponentState =
createComponentState<boolean>({
key: 'isRecordTableScrolledLeftComponentState',
key: 'isRecordTableScrolledVerticallyComponentState',
componentInstanceContext: RecordTableComponentInstanceContext,
defaultValue: true,
});