Sticky headers for settings tables (#11826)

- https://github.com/enso-org/cloud-v2/issues/1574
- Make header rows sticky
- Use solid background on header rows User Groups and Members tables in settings to avoid overlapping text
- https://github.com/enso-org/cloud-v2/issues/895
- Make "name" column sticky in assets table

# Important Notes
None
This commit is contained in:
somebody1234 2024-12-12 09:57:17 +10:00 committed by GitHub
parent ffec1c8004
commit ffd0de4661
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 82 additions and 98 deletions

View File

@ -2,6 +2,7 @@
import * as React from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import { useStore } from 'zustand'
import BlankIcon from '#/assets/blank.svg'
@ -33,6 +34,7 @@ import * as localBackend from '#/services/LocalBackend'
import * as backendModule from '#/services/Backend'
import { Text } from '#/components/AriaComponents'
import { IndefiniteSpinner } from '#/components/Spinner'
import type { AssetEvent } from '#/events/assetEvent'
import { useCutAndPaste } from '#/events/assetListEvent'
import {
@ -56,8 +58,6 @@ import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import invariant from 'tiny-invariant'
import { IndefiniteSpinner } from '../Spinner'
// =================
// === Constants ===

View File

@ -69,7 +69,7 @@ const NORMAL_COLUMN_CSS_CLASSES = `px-cell-x py ${COLUMN_CSS_CLASSES}`
/** CSS classes for every column. */
export const COLUMN_CSS_CLASS: Readonly<Record<Column, string>> = {
[Column.name]: `rounded-rows-skip-level min-w-drive-name-column h-full p-0 border-l-0 ${COLUMN_CSS_CLASSES}`,
[Column.name]: `z-10 sticky left-0 bg-dashboard rounded-rows-skip-level min-w-drive-name-column h-full p-0 border-l-0 ${COLUMN_CSS_CLASSES}`,
[Column.modified]: `min-w-drive-modified-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.sharedWith]: `min-w-drive-shared-with-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.labels]: `min-w-drive-labels-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`,

View File

@ -41,7 +41,7 @@ export default function NameColumnHeading(props: AssetColumnHeadingProps) {
getText('stopSortingByName')
: getText('sortByNameDescending')
}
className="group flex h-table-row w-full items-center justify-start gap-icon-with-text px-name-column-x"
className="group sticky left-0 flex h-table-row w-full items-center justify-start gap-icon-with-text bg-dashboard px-name-column-x"
onPress={cycleSortDirection}
>
<Text className="text-sm font-semibold">{getText('nameColumnName')}</Text>

View File

@ -3,7 +3,6 @@ import { useRef, useState, type MutableRefObject, type RefObject } from 'react'
import { useSyncRef } from '#/hooks/syncRefHooks'
import useOnScroll from '#/hooks/useOnScroll'
import { unsafeWriteValue } from '#/utilities/write'
/** Options for the {@link useStickyTableHeaderOnScroll} hook. */
interface UseStickyTableHeaderOnScrollOptions {
@ -29,12 +28,7 @@ export function useStickyTableHeaderOnScroll(
const trackShadowClassRef = useSyncRef(trackShadowClass)
const [shadowClassName, setShadowClass] = useState('')
const onScroll = useOnScroll(() => {
if (rootRef.current != null && bodyRef.current != null) {
unsafeWriteValue(
bodyRef.current.style,
'clipPath',
`inset(${rootRef.current.scrollTop}px 0 0 0)`,
)
if (rootRef.current && bodyRef.current) {
if (trackShadowClassRef.current) {
const isAtTop = rootRef.current.scrollTop === 0
const isAtBottom =

View File

@ -44,6 +44,7 @@ import Label from '#/components/dashboard/Label'
import { ErrorDisplay } from '#/components/ErrorBoundary'
import { IsolateLayout } from '#/components/IsolateLayout'
import SelectionBrush from '#/components/SelectionBrush'
import { IndefiniteSpinner } from '#/components/Spinner'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import { ASSETS_MIME_TYPE } from '#/data/mimeTypes'
@ -140,7 +141,6 @@ import { EMPTY_SET, setPresence, withPresence } from '#/utilities/set'
import type { SortInfo } from '#/utilities/sorting'
import { twJoin, twMerge } from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import { IndefiniteSpinner } from '../components/Spinner'
declare module '#/utilities/LocalStorage' {
/** */
@ -1806,8 +1806,8 @@ function AssetsTable(props: AssetsTableProps) {
}
}}
>
<table className="rr-block isolate table-fixed border-collapse rounded-rows">
<thead className="sticky top-0 z-1 bg-dashboard">{headerRow}</thead>
<table className="isolate table-fixed border-collapse rounded-rows">
<thead className="sticky top-0 z-[11] bg-dashboard">{headerRow}</thead>
<tbody ref={bodyRef}>
{itemRows}
<tr className="hidden h-row first:table-row">

View File

@ -73,7 +73,7 @@ export default function KeyboardShortcutsSettingsSection() {
})}
>
<table className="table-fixed border-collapse rounded-rows">
<thead className="sticky top-0">
<thead className="sticky top-0 z-1 bg-dashboard">
<tr className="h-row text-left text-sm font-semibold">
<th className="min-w-8 pl-cell-x pr-1.5">{/* Icon */}</th>
<th className="min-w-36 px-cell-x">{getText('name')}</th>

View File

@ -16,16 +16,8 @@ import InviteUsersModal from '#/modals/InviteUsersModal'
import type * as backendModule from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
// =================
// === Constants ===
// =================
const LIST_USERS_STALE_TIME_MS = 60_000
// ==============================
// === MembersSettingsSection ===
// ==============================
/** Settings tab for viewing and editing organization members. */
export default function MembersSettingsSection() {
const { getText } = textProvider.useText()
@ -87,71 +79,73 @@ export default function MembersSettingsSection() {
</ariaComponents.ButtonGroup>
)}
<table className="table-fixed self-start rounded-rows">
<thead>
<tr className="h-row">
<th className="min-w-48 max-w-80 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('name')}
</th>
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('status')}
</th>
</tr>
</thead>
<tbody className="select-text">
{members.map((member) => (
<tr key={member.email} className="group h-row rounded-rows-child">
<td className="min-w-48 max-w-80 border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<ariaComponents.Text truncate="1" className="block">
{member.email}
</ariaComponents.Text>
<ariaComponents.Text truncate="1" className="block text-2xs text-primary/40">
{member.name}
</ariaComponents.Text>
</td>
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
<div className="flex flex-col">
{getText('active')}
{member.email !== user.email && isAdmin && (
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
<RemoveMemberButton backend={backend} userId={member.userId} />
</ariaComponents.ButtonGroup>
)}
</div>
</td>
<div className="min-h-0 flex-1 overflow-auto">
<table className="table-fixed self-start rounded-rows">
<thead className="sticky top-0 z-1 bg-dashboard">
<tr className="h-row">
<th className="min-w-48 max-w-80 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('name')}
</th>
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('status')}
</th>
</tr>
))}
{invitations.invitations.map((invitation) => (
<tr key={invitation.userEmail} className="group h-row rounded-rows-child">
<td className="border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<span className="block text-sm">{invitation.userEmail}</span>
</td>
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
<div className="flex flex-col">
{getText('pendingInvitation')}
{isAdmin && (
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
<ariaComponents.CopyButton
size="custom"
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
copyText={`enso://auth/registration?=${new URLSearchParams({ organization_id: invitation.organizationId }).toString()}`}
aria-label={getText('copyInviteLink')}
copyIcon={false}
>
{getText('copyInviteLink')}
</ariaComponents.CopyButton>
</thead>
<tbody className="select-text">
{members.map((member) => (
<tr key={member.email} className="group h-row rounded-rows-child">
<td className="min-w-48 max-w-80 border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<ariaComponents.Text truncate="1" className="block">
{member.email}
</ariaComponents.Text>
<ariaComponents.Text truncate="1" className="block text-2xs text-primary/40">
{member.name}
</ariaComponents.Text>
</td>
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
<div className="flex flex-col">
{getText('active')}
{member.email !== user.email && isAdmin && (
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
<RemoveMemberButton backend={backend} userId={member.userId} />
</ariaComponents.ButtonGroup>
)}
</div>
</td>
</tr>
))}
{invitations.invitations.map((invitation) => (
<tr key={invitation.userEmail} className="group h-row rounded-rows-child">
<td className="border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<span className="block text-sm">{invitation.userEmail}</span>
</td>
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
<div className="flex flex-col">
{getText('pendingInvitation')}
{isAdmin && (
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
<ariaComponents.CopyButton
size="custom"
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
copyText={`enso://auth/registration?=${new URLSearchParams({ organization_id: invitation.organizationId }).toString()}`}
aria-label={getText('copyInviteLink')}
copyIcon={false}
>
{getText('copyInviteLink')}
</ariaComponents.CopyButton>
<ResendInvitationButton invitation={invitation} backend={backend} />
<ResendInvitationButton invitation={invitation} backend={backend} />
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
</ariaComponents.ButtonGroup>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
</ariaComponents.ButtonGroup>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}

View File

@ -21,10 +21,6 @@ import { UserId, type User } from '#/services/Backend'
import { twMerge } from '#/utilities/tailwindMerge'
import UserRow from './UserRow'
// ====================
// === MembersTable ===
// ====================
/** Props for a {@link MembersTable}. */
export interface MembersTableProps {
readonly backend: Backend
@ -129,7 +125,7 @@ export default function MembersTable(props: MembersTableProps) {
className="w-settings-main-section max-w-full table-fixed self-start rounded-rows"
{...(draggable ? { dragAndDropHooks } : {})}
>
<TableHeader className="sticky top h-row">
<TableHeader className="sticky top-0 z-1 h-row bg-dashboard">
<Column
isRowHeader
className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"

View File

@ -29,14 +29,14 @@ function SettingsSection(props: SettingsSectionProps) {
}
return (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full flex-1 flex-col gap-2.5 overflow-auto">
{!heading ? null : (
<Text.Heading level={2} weight="bold">
{getText(nameId)}
</Text.Heading>
)}
<div className="flex flex-col gap-2">
<div className="flex min-h-0 flex-1 flex-col justify-start gap-2">
{entries.map((entry, i) => (
<SettingsEntry key={i} context={context} data={entry} />
))}

View File

@ -77,7 +77,7 @@ export default function SettingsTab(props: SettingsTabProps) {
{...contentProps}
>
{columns.map((sectionsInColumn, i) => (
<div key={i} className={twMerge('flex h-fit flex-1 flex-col gap-8 pb-12', classes[i])}>
<div key={i} className={twMerge('flex h-fit flex-1 flex-col gap-8', classes[i])}>
{sectionsInColumn.map((section) => (
<SettingsSection key={section.nameId} context={context} data={section} />
))}

View File

@ -139,7 +139,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
return (
<>
{isAdmin && (
<ButtonGroup verticalAlign="center">
<ButtonGroup verticalAlign="center" className="flex-initial">
{shouldDisplayPaywall && (
<PaywallDialogButton
feature="userGroupsFull"
@ -178,7 +178,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
<div
ref={rootRef}
className={twMerge(
'overflow-auto overflow-x-hidden transition-all lg:mb-2',
'min-h-0 flex-initial overflow-y-auto overflow-x-hidden transition-all lg:mb-2',
shadowClassName,
)}
onScroll={onUserGroupsTableScroll}
@ -188,7 +188,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
className="w-full max-w-3xl table-fixed self-start rounded-rows"
dragAndDropHooks={dragAndDropHooks}
>
<TableHeader className="sticky top h-row">
<TableHeader className="sticky top-0 z-1 h-row bg-dashboard">
<Column
isRowHeader
className="w-full border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"

View File

@ -391,7 +391,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
sections: [
{
nameId: 'userGroupsSettingsSection',
columnClassName: 'h-3/5 lg:h-[unset] overflow-auto',
columnClassName: 'lg:h-[unset] overflow-auto h-auto',
entries: [
{
type: 'custom',
@ -402,7 +402,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
{
nameId: 'userGroupsUsersSettingsSection',
column: 2,
columnClassName: 'h-2/5 lg:h-[unset] overflow-auto',
columnClassName: 'lg:h-[unset] overflow-auto h-auto',
entries: [
{
type: 'custom',