mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
fix: keep the instance users table's actions and header in view (#11145)
* fix: keep the instance users table's actions and header in view Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pMjimm9tUMXtD8rkWB62w * fix: lock only the User option for group-granted roles and keep pinned cells opaque Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pMjimm9tUMXtD8rkWB62w * fix: close the instance settings drawer from the manage-in-workspace menu item Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pMjimm9tUMXtD8rkWB62w --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
co-authored by
Claude Fable 5.1
Ruben Fiszel
parent
d0d5b295b9
commit
a9a9335a34
@@ -3,6 +3,7 @@
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import { autoPlacement } from '@floating-ui/core'
|
||||
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
email: string
|
||||
@@ -24,9 +25,22 @@
|
||||
closeButton
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button color={isConflict ? 'red' : 'light'} size="xs" spacingSize="xs2" nonCaptureEvent={true}
|
||||
>{isConflict ? 'Fix username conflict' : 'Change username'}</Button
|
||||
>
|
||||
{#if isConflict}
|
||||
<!-- An icon rather than a labelled button: the username column truncates text but
|
||||
cannot truncate a button, so a wide trigger here forced the whole table to scroll. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: AlertTriangle }}
|
||||
btnClasses="text-yellow-600 dark:text-yellow-400"
|
||||
title="No instance username. Click to fix the conflict."
|
||||
aria-label="Fix username conflict"
|
||||
nonCaptureEvent={true}
|
||||
/>
|
||||
{:else}
|
||||
<Button variant="default" unifiedSize="xs" nonCaptureEvent={true}>Change username</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<ChangeInstanceUsernameInner
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="1200px" {disableChatOffset} on:close={handleDrawerClose}>
|
||||
<Drawer bind:this={drawer} size="1300px" {disableChatOffset} on:close={handleDrawerClose}>
|
||||
<DrawerContent noPadding overflow_y={false} title="Instance settings" on:close={handleClose}>
|
||||
{#snippet titleExtra()}
|
||||
<MeltTooltip disablePopup={!uptodateVersion}>
|
||||
|
||||
@@ -26,11 +26,10 @@
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Settings,
|
||||
UserMinus,
|
||||
UserPlus
|
||||
} from 'lucide-svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
@@ -155,6 +154,11 @@
|
||||
loadExtJwtPage(1)
|
||||
|
||||
let tab: string = $state('users')
|
||||
let usersListShown = $derived(
|
||||
tab === 'users' &&
|
||||
!yamlMode &&
|
||||
(usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0))
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
tab = $instanceSettingsSelectedTab
|
||||
@@ -320,11 +324,14 @@
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<div class="h-full overflow-auto bg-surface">
|
||||
<div class="h-fit px-8 py-4">
|
||||
<!-- The users list scrolls inside a bounded table, so its tab fills the pane instead of
|
||||
growing with it: that is what lets the header pin and keeps the horizontal
|
||||
scrollbar in view rather than under hundreds of rows. -->
|
||||
<div class={usersListShown ? 'h-full flex flex-col px-8 py-4' : 'h-fit px-8 py-4'}>
|
||||
{#if tab === 'ai' && !yamlMode}
|
||||
<InstanceAISettings {disableChatOffset} />
|
||||
{:else if tab === 'users' && !yamlMode}
|
||||
<div class="h-full">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
{#if !automateUsernameCreation && !isCloudHosted()}
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2"> Automatic username creation </h3>
|
||||
@@ -373,7 +380,7 @@
|
||||
<Tab value="guests" label="Guests" />
|
||||
</Tabs>
|
||||
|
||||
{#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0)}
|
||||
{#if usersListShown}
|
||||
<SettingsPageHeader
|
||||
title="Instance users ({users.length})"
|
||||
description="Manage all users across your Windmill instance."
|
||||
@@ -414,14 +421,10 @@
|
||||
<p class="text-hint text-2xs mt-2">
|
||||
{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
<div class="mt-1">
|
||||
<DataTable
|
||||
shouldLoadMore={(filteredUsers?.length ?? 0) > 50}
|
||||
loadMore={50}
|
||||
on:loadMore={() => {
|
||||
nbDisplayed += 50
|
||||
}}
|
||||
>
|
||||
<!-- Shrinks but never grows: a short list keeps its box hugging the rows, a long one
|
||||
is capped by the pane and scrolls inside, with a floor of a few rows. -->
|
||||
<div class="mt-1 min-h-48">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Email</Cell>
|
||||
@@ -434,7 +437,7 @@
|
||||
<Cell head>Kind</Cell>
|
||||
{/if}
|
||||
<Cell head>Role</Cell>
|
||||
<Cell head last>
|
||||
<Cell head last actions>
|
||||
<span class="sr-only">Actions</span>
|
||||
</Cell>
|
||||
</tr>
|
||||
@@ -443,12 +446,22 @@
|
||||
{#if filteredUsers && users}
|
||||
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, is_workspace_admin, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
|
||||
{@const isServiceAccount = login_type === 'service_account'}
|
||||
{@const groupRole =
|
||||
role_source === 'instance_group' && (super_admin || devops)}
|
||||
<!-- Any elevated role picked here is stored as manual and wins over the group on later
|
||||
syncs; only a demotion to User is re-applied from the group. So only User locks. -->
|
||||
{@const groupRoleTooltip =
|
||||
'Role is set by an instance group. Superadmin and Devops can be set here, but demoting to User requires removing the user from the group.'}
|
||||
{@const serviceAccountTooltip =
|
||||
'Service accounts are always users in the instance. Their workspace role is managed in the workspace user settings.'}
|
||||
<!-- Dimmed per cell content, not on the row: opacity on the row would make the pinned
|
||||
actions cell translucent and let the columns scrolling under it show through. -->
|
||||
<tr
|
||||
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
|
||||
? 'opacity-60'
|
||||
? '[&>td>*]:opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<Cell first class="max-w-[250px]">
|
||||
<Cell first class="max-w-[240px]">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if isServiceAccount}
|
||||
<Bot size={16} class="text-blue-500 shrink-0" />
|
||||
@@ -458,14 +471,6 @@
|
||||
>{email}</a
|
||||
>
|
||||
{/if}
|
||||
{#if workspace_id}
|
||||
<a
|
||||
href="{base}/?workspace={workspace_id}"
|
||||
title="Workspace: {workspace_id}"
|
||||
>
|
||||
<Badge color="blue">{truncate(workspace_id, 20)}</Badge>
|
||||
</a>
|
||||
{/if}
|
||||
{#if disabled}
|
||||
<span
|
||||
class="text-2xs px-1.5 py-0.5 rounded bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300 whitespace-nowrap"
|
||||
@@ -475,7 +480,7 @@
|
||||
</div>
|
||||
</Cell>
|
||||
{#if automateUsernameCreation}
|
||||
<Cell class="max-w-[150px]">
|
||||
<Cell class="max-w-[140px]">
|
||||
{#if username}
|
||||
<span title={username} class="truncate block">{username}</span>
|
||||
{:else}
|
||||
@@ -503,133 +508,157 @@
|
||||
>
|
||||
{#if activeOnly}
|
||||
<Cell>
|
||||
{#if is_workspace_admin}
|
||||
Admin
|
||||
{:else if operator_only}
|
||||
Operator only
|
||||
{:else}
|
||||
Developer
|
||||
{/if}
|
||||
<span>
|
||||
{#if is_workspace_admin}
|
||||
Admin
|
||||
{:else if operator_only}
|
||||
Operator only
|
||||
{:else}
|
||||
Developer
|
||||
{/if}
|
||||
</span>
|
||||
</Cell>
|
||||
{/if}
|
||||
<Cell>
|
||||
{#if isServiceAccount}
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- A service account has no `password` row, so it can never hold an
|
||||
instance role: the group renders locked on "User" rather than hidden. -->
|
||||
<div class="flex flex-col items-start">
|
||||
{#key `${super_admin}_${devops}_${role_source}`}
|
||||
<ToggleButtonGroup
|
||||
disabled={isServiceAccount}
|
||||
selected={super_admin
|
||||
? 'super_admin'
|
||||
: devops
|
||||
? 'devops'
|
||||
: 'user'}
|
||||
on:selected={async (e) => {
|
||||
if (email == $userStore?.email) {
|
||||
sendUserToast('You cannot demote yourself', true)
|
||||
listUsers(activeOnly)
|
||||
return
|
||||
}
|
||||
|
||||
let role = e.detail
|
||||
|
||||
if (role === 'super_admin') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: true,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'devops') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: true
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'user') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
sendUserToast('User updated')
|
||||
listUsers(activeOnly)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value={'user'}
|
||||
small
|
||||
label="User"
|
||||
shortLabel="User"
|
||||
disabled={isServiceAccount || groupRole}
|
||||
tooltip={isServiceAccount
|
||||
? serviceAccountTooltip
|
||||
: groupRole
|
||||
? groupRoleTooltip
|
||||
: undefined}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'devops'}
|
||||
small
|
||||
label="Devops"
|
||||
shortLabel="Dev"
|
||||
disabled={isServiceAccount}
|
||||
tooltip={isServiceAccount
|
||||
? serviceAccountTooltip
|
||||
: "Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'super_admin'}
|
||||
small
|
||||
label="Superadmin"
|
||||
shortLabel="Admin"
|
||||
disabled={isServiceAccount}
|
||||
tooltip={isServiceAccount
|
||||
? serviceAccountTooltip
|
||||
: undefined}
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/key}
|
||||
{#if isServiceAccount}
|
||||
<span
|
||||
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
|
||||
class="text-2xs text-tertiary mt-0.5 ml-1 whitespace-nowrap"
|
||||
title={serviceAccountTooltip}
|
||||
>
|
||||
{is_workspace_admin
|
||||
? 'Admin'
|
||||
: operator_only
|
||||
? 'Operator'
|
||||
: 'Developer'}
|
||||
in
|
||||
{#if workspace_id}
|
||||
<a
|
||||
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
|
||||
class="hover:underline"
|
||||
title={workspace_id}
|
||||
onclick={() => closeDrawer?.()}
|
||||
>{truncate(workspace_id, 20)}</a
|
||||
>
|
||||
{:else}
|
||||
its workspace
|
||||
{/if}
|
||||
</span>
|
||||
<Tooltip>
|
||||
Service-account role is managed in the workspace user settings.
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-start">
|
||||
{#key `${super_admin}_${devops}_${role_source}`}
|
||||
<ToggleButtonGroup
|
||||
selected={super_admin
|
||||
? 'super_admin'
|
||||
: devops
|
||||
? 'devops'
|
||||
: 'user'}
|
||||
on:selected={async (e) => {
|
||||
if (email == $userStore?.email) {
|
||||
sendUserToast('You cannot demote yourself', true)
|
||||
listUsers(activeOnly)
|
||||
return
|
||||
}
|
||||
|
||||
let role = e.detail
|
||||
|
||||
if (role === 'super_admin') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: true,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'devops') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: true
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'user') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
sendUserToast('User updated')
|
||||
listUsers(activeOnly)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value={'user'}
|
||||
small
|
||||
label="User"
|
||||
disabled={role_source === 'instance_group' &&
|
||||
(super_admin || devops)}
|
||||
tooltip={role_source === 'instance_group' &&
|
||||
(super_admin || devops)
|
||||
? 'Role is set by an instance group. Remove the user from the group to demote to "User".'
|
||||
: undefined}
|
||||
showTooltipIcon={role_source === 'instance_group' &&
|
||||
(super_admin || devops)}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'devops'}
|
||||
small
|
||||
label="Devops"
|
||||
tooltip="Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'super_admin'}
|
||||
small
|
||||
label="Superadmin"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/key}
|
||||
{#if role_source === 'instance_group' && (super_admin || devops)}
|
||||
<a
|
||||
href="{base}/groups"
|
||||
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
|
||||
title="Role set by instance group. You can upgrade to a higher role manually, but demoting to "User" requires removing them from the group."
|
||||
onclick={() => closeDrawer?.()}
|
||||
>
|
||||
Set by instance group
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if groupRole}
|
||||
<a
|
||||
href="{base}/groups"
|
||||
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
|
||||
title={groupRoleTooltip}
|
||||
onclick={() => closeDrawer?.()}
|
||||
>
|
||||
Set by instance group
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
<Cell last actions class={i % 2 === 0 ? 'bg-surface-tertiary' : ''}>
|
||||
<div class="flex items-center justify-end">
|
||||
{#if isServiceAccount}
|
||||
{#if workspace_id}
|
||||
<a
|
||||
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
|
||||
class="text-xs text-secondary hover:text-primary hover:underline"
|
||||
title="Manage in workspace settings">Manage in workspace</a
|
||||
>
|
||||
<DropdownV2
|
||||
items={[
|
||||
{
|
||||
displayName: 'Manage in workspace',
|
||||
icon: Settings,
|
||||
action: () => closeDrawer?.(),
|
||||
href: `${base}/workspace_settings?tab=users&workspace=${workspace_id}`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
@@ -722,6 +751,29 @@
|
||||
</Cell>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if filteredUsers.length > nbDisplayed}
|
||||
{@const remaining = Math.min(50, filteredUsers.length - nbDisplayed)}
|
||||
<!-- Last row rather than a footer under the scroller, the way the runs
|
||||
list pages: the control scrolls with the rows it extends. -->
|
||||
<tr>
|
||||
<Cell
|
||||
colspan={5 +
|
||||
(automateUsernameCreation ? 1 : 0) +
|
||||
(activeOnly ? 1 : 0)}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
wrapperClasses="w-full justify-center"
|
||||
onClick={() => {
|
||||
nbDisplayed += 50
|
||||
}}
|
||||
>
|
||||
Load next {remaining} user{remaining !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</Cell>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
interface Props {
|
||||
label?: string | undefined
|
||||
/** Shown instead of `label` below the `xl` breakpoint, for groups that must keep
|
||||
* their width inside a narrow table cell. The full label stays the accessible name. */
|
||||
shortLabel?: string | undefined
|
||||
iconOnly?: boolean
|
||||
tooltip?: string | undefined
|
||||
icon?: any | undefined
|
||||
@@ -30,6 +33,7 @@
|
||||
|
||||
let {
|
||||
label = undefined,
|
||||
shortLabel = undefined,
|
||||
iconOnly = false,
|
||||
tooltip = undefined,
|
||||
icon = undefined,
|
||||
@@ -68,6 +72,7 @@
|
||||
<button
|
||||
{id}
|
||||
{disabled}
|
||||
aria-label={shortLabel ? label : undefined}
|
||||
class={twMerge(
|
||||
'group rounded-md transition-all font-normal flex gap-1 flex-row items-center justify-center border text-xs',
|
||||
horizontalPadding,
|
||||
@@ -75,7 +80,7 @@
|
||||
'text-primary data-[state=on]:text-primary',
|
||||
'data-[state=on]:bg-surface-tertiary data-[state=off]:border-transparent data-[state=on]:border-border-normal/30',
|
||||
'bg-surface-transparent hover:bg-surface-hover',
|
||||
disabled ? '!shadow-none' : '',
|
||||
disabled ? '!shadow-none !text-disabled' : '',
|
||||
className
|
||||
)}
|
||||
use:melt={$item(value)}
|
||||
@@ -102,7 +107,12 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if label && !iconOnly}
|
||||
{label}
|
||||
{#if shortLabel}
|
||||
<span class="hidden xl:inline">{label}</span>
|
||||
<span class="xl:hidden" aria-hidden="true">{shortLabel}</span>
|
||||
{:else}
|
||||
{label}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showTooltipIcon}
|
||||
<Info size={iconSize} class="text-gray-400" />
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<Cell head first>Email</Cell>
|
||||
<Cell head>Username</Cell>
|
||||
<Cell head>Role</Cell>
|
||||
<Cell head last><span class="sr-only">Actions</span></Cell>
|
||||
<Cell head last actions><span class="sr-only">Actions</span></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody>
|
||||
@@ -187,7 +187,7 @@
|
||||
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}
|
||||
</Badge>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
<Cell last actions>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
|
||||
@@ -823,7 +823,7 @@
|
||||
</Cell>
|
||||
<Cell head>Role</Cell>
|
||||
<Cell head>Enabled</Cell>
|
||||
<Cell head last>
|
||||
<Cell head last actions>
|
||||
<span class="sr-only">Actions</span>
|
||||
</Cell>
|
||||
</tr>
|
||||
@@ -963,7 +963,7 @@
|
||||
size="xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Cell last actions class={index % 2 === 0 ? 'bg-surface-tertiary' : ''}>
|
||||
<div class="flex gap-1">
|
||||
{#if user.is_service_account && $userStore?.is_admin}
|
||||
<Button
|
||||
@@ -1085,7 +1085,7 @@
|
||||
<tr>
|
||||
<Cell head first>Email</Cell>
|
||||
<Cell head>Role</Cell>
|
||||
<Cell head last><span class="sr-only">Actions</span></Cell>
|
||||
<Cell head last actions><span class="sr-only">Actions</span></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
@@ -1142,7 +1142,7 @@
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
<Cell last actions>
|
||||
<Button
|
||||
variant="default"
|
||||
destructive
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
headerAction?: import('svelte').Snippet;
|
||||
children?: import('svelte').Snippet
|
||||
headerAction?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { children, headerAction }: Props = $props();
|
||||
let { children, headerAction }: Props = $props()
|
||||
</script>
|
||||
|
||||
<thead class="bg-surface-secondary sticky top-0 z-10 border-b">
|
||||
<!-- Stuck 1px above the scroller's edge rather than at it: at fractional device scale
|
||||
factors the edge and the header round to different pixels, and rows scrolling
|
||||
beneath show through the seam. Clipping the header's top row hides it. -->
|
||||
<thead class="bg-surface-secondary sticky -top-px z-10 border-b">
|
||||
{@render children?.()}
|
||||
<tr class="absolute top-2 right-2">
|
||||
{@render headerAction?.()}
|
||||
|
||||
Reference in New Issue
Block a user