feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 16:58:01 +01:00
committed by GitHub
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
@@ -26,8 +26,9 @@
>
<StyledInput
v-model="commandInput"
v-tooltip="disableInput ? disableInputTooltip : undefined"
:icon="TerminalSquareIcon"
:placeholder="disableInput ? 'Server is not running' : 'Send a command'"
:placeholder="disableInput ? disabledInputPlaceholder : 'Send a command'"
:disabled="disableInput"
wrapper-class="w-full"
input-class="!h-10"
@@ -51,6 +52,8 @@ const props = withDefaults(
scrollback?: number
showInput?: boolean
disableInput?: boolean
disableInputTooltip?: string
disabledInputPlaceholder?: string
fullscreen?: boolean
emptyStateType?: 'server' | 'instance'
loading?: boolean
@@ -59,6 +62,8 @@ const props = withDefaults(
scrollback: Infinity,
showInput: false,
disableInput: false,
disableInputTooltip: undefined,
disabledInputPlaceholder: 'Server is not running',
fullscreen: false,
emptyStateType: undefined,
loading: false,
@@ -215,6 +220,7 @@ watch(
)
const submitCommand = () => {
if (props.disableInput) return
const cmd = commandInput.value.trim()
if (!cmd) return
emit('command', cmd)
@@ -349,20 +349,19 @@ const fontSize = computed(() => {
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child,
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child > svg,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child {
> svg {
display: block;
width: var(--_icon-size, 1rem);
height: var(--_icon-size, 1rem);
min-width: var(--_icon-size, 1rem);
min-height: var(--_icon-size, 1rem);
}
+38 -7
View File
@@ -23,6 +23,10 @@
type="text"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
:autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize"
:spellcheck="searchSpellcheck"
wrapper-class="w-full !bg-transparent"
:input-class="searchableInputClass"
class="relative z-[1]"
@@ -73,7 +77,7 @@
<slot name="selected" :label="triggerText">{{ triggerText }}</slot>
</span>
</div>
<div class="flex items-center gap-1">
<div class="flex shrink-0 items-center gap-1">
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
@@ -97,6 +101,7 @@
:class="[
props.dropdownClass,
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
props.dropdownClass,
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@@ -174,7 +179,7 @@
</div>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
<div v-else-if="searchQuery" class="p-4 text-center text-sm text-secondary">
{{ noOptionsMessage }}
</div>
@@ -276,12 +281,19 @@ const props = withDefaults(
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
dropdownClass?: string
dropdownMinWidth?: string
minSearchLengthToOpen?: number
/** Keep the selected option's label in the input after selection, and show all options on focus */
syncWithSelection?: boolean
/** Select the searchable input text when the field receives focus */
selectSearchTextOnFocus?: boolean
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
searchAutocomplete?: string
searchAutocorrect?: 'on' | 'off'
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean
}>(),
{
placeholder: 'Select an option',
@@ -293,6 +305,7 @@ const props = withDefaults(
showIconInSelected: false,
maxHeight: DEFAULT_MAX_HEIGHT,
noOptionsMessage: 'No results found',
minSearchLengthToOpen: 0,
syncWithSelection: true,
selectSearchTextOnFocus: false,
showSearchIcon: false,
@@ -377,6 +390,10 @@ const triggerText = computed(() => {
return props.placeholder
})
const hasMinimumSearchLength = computed(
() => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen,
)
const optionsWithKeys = computed(() => {
return props.options.map((opt, index) => ({
...opt,
@@ -413,8 +430,7 @@ function getOptionClasses(item: ComboboxOption<T> & { key: string }, _index: num
item.class,
{
'bg-surface-4 text-contrast hover:brightness-[115%] focus:brightness-[115%]': !isSelected,
'bg-highlight-green text-green !cursor-default hover:bg-highlight-green focus:bg-highlight-green':
isSelected,
'bg-highlight-green text-green hover:bg-highlight-green focus:bg-highlight-green': isSelected,
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
@@ -583,7 +599,8 @@ function destroyOptionsOverlayScrollbars() {
}
async function openDropdown() {
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
if (props.disabled || isOpen.value || !hasMinimumSearchLength.value || !hasDropdownContent.value)
return
isOpen.value = true
emit('open')
@@ -628,7 +645,11 @@ function handleTriggerClick(event: MouseEvent) {
function handleOptionClick(option: ComboboxOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
const isSelected = props.listbox && option.value === props.modelValue
if (isSelected) return
if (isSelected) {
focusedIndex.value = index
if (option.type !== 'link') closeDropdown()
return
}
focusedIndex.value = index
@@ -782,6 +803,10 @@ function handleSearchKeydown(event: KeyboardEvent) {
function handleSearchInput() {
userHasTyped.value = true
emit('searchInput', searchQuery.value)
if (!hasMinimumSearchLength.value) {
closeDropdown()
return
}
if (!isOpen.value) {
openDropdown()
}
@@ -906,10 +931,16 @@ watch(hasDropdownContent, (value) => {
}
})
watch(hasMinimumSearchLength, (canOpen) => {
if (!canOpen) {
closeDropdown()
}
})
watch(
[() => props.modelValue, () => props.options],
([val]) => {
if (props.searchable && props.syncWithSelection && !isOpen.value) {
if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) {
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
}
@@ -109,7 +109,7 @@
<button
ref="addMenuTrigger"
type="button"
:class="addButtonClass"
:class="addButtonClass ?? '!border'"
:aria-expanded="isAddMenuOpen"
aria-haspopup="menu"
@click="handleAddMenuTriggerClick"
@@ -262,58 +262,82 @@
:style="activeCategoryOptionsListStyle"
>
<div
v-for="{ option, index } in renderedVisibleActiveCategoryOptions"
:key="`${activeCategory.key}-${option.value}`"
v-for="{ item, index } in renderedVisibleActiveCategoryOptions"
:key="getActiveCategoryItemKey(item, index)"
:class="shouldVirtualizeActiveCategoryOptions ? 'absolute left-0 right-0' : undefined"
:style="getActiveCategoryOptionWrapperStyle(index)"
>
<div
v-if="isDropdownFilterSectionHeader(item)"
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-semibold text-secondary"
:class="item.class"
>
<span class="flex min-w-0 items-center gap-2">
<component
:is="item.icon"
v-if="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="min-w-0 truncate">{{ item.label }}</span>
</span>
<button
v-if="hasSelectableSectionHeaderOptions(item)"
type="button"
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:text-contrast"
@click="toggleSectionHeaderOptions(item)"
>
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
</button>
</div>
<button
v-else
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 border-0 px-4 py-3.5 text-left text-contrast shadow-none transition-all duration-150 bg-surface-4 hover:brightness-[115%] focus-visible:brightness-[115%] focus-visible:outline-none"
:class="[
shouldVirtualizeActiveCategoryOptions ? 'h-12' : undefined,
{
'brightness-[115%]': option.selected,
'pointer-events-none cursor-not-allowed opacity-50': option.disabled,
'brightness-[115%]': item.selected,
'pointer-events-none cursor-not-allowed opacity-50': item.disabled,
},
]"
:aria-disabled="option.disabled || undefined"
:aria-checked="option.selected"
:aria-disabled="item.disabled || undefined"
:aria-checked="item.selected"
role="checkbox"
@click="toggleFilterOption(activeCategory.key, option)"
@click="toggleFilterOption(activeCategory.key, item)"
>
<span
v-if="checkboxPosition === 'left'"
class="checkbox-shadow flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-[1px] border-solid"
:class="
option.selected
item.selected
? 'border-button-border bg-brand text-brand-inverted'
: 'border-surface-5 bg-surface-2'
"
>
<CheckIcon v-if="option.selected" aria-hidden="true" stroke-width="3" />
<CheckIcon v-if="item.selected" aria-hidden="true" stroke-width="3" />
</span>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<slot
v-if="$slots.option"
name="option"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
:index="index"
></slot>
<template v-else>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="option.selected ? 'text-contrast' : 'text-primary'"
:class="item.selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
{{ item.label }}
</span>
<slot
name="option-right"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
></slot>
</template>
</div>
@@ -321,7 +345,7 @@
v-if="checkboxPosition === 'right'"
class="flex shrink-0 items-center justify-center text-brand"
>
<CheckIcon v-if="option.selected" aria-hidden="true" class="size-5" />
<CheckIcon v-if="item.selected" aria-hidden="true" class="size-5" />
</span>
</button>
</div>
@@ -353,12 +377,12 @@ import {
} from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import type { ComponentPublicInstance, CSSProperties } from 'vue'
import type { Component, ComponentPublicInstance, CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import ButtonStyled from './ButtonStyled.vue'
import MultiSelect, { type MultiSelectOption } from './MultiSelect.vue'
import MultiSelect, { type MultiSelectItem } from './MultiSelect.vue'
import StyledInput from './StyledInput.vue'
export type DropdownFilterBarOption = {
@@ -368,10 +392,20 @@ export type DropdownFilterBarOption = {
disabled?: boolean
}
export type DropdownFilterBarSectionHeader = {
type: 'section-header'
label: string
key?: string
icon?: Component
class?: string
}
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
export type DropdownFilterBarCategory = {
key: string
label: string
options: DropdownFilterBarOption[]
options: DropdownFilterBarItem[]
syntheticOptions?: DropdownFilterBarOption[]
searchable?: boolean
searchPlaceholder?: string
@@ -388,8 +422,12 @@ type RenderedDropdownFilterBarOption = DropdownFilterBarOption & {
selected: boolean
}
type RenderedDropdownFilterBarItem =
| RenderedDropdownFilterBarOption
| DropdownFilterBarSectionHeader
type VisibleDropdownFilterBarOption = {
option: RenderedDropdownFilterBarOption
item: RenderedDropdownFilterBarItem
index: number
}
@@ -464,6 +502,7 @@ const props = withDefaults(
showClear?: boolean
showLabel?: boolean
useFilterIcon?: boolean
applyImmediately?: boolean
showPreviewFilterIcon?: boolean
previewTriggerClass?: string
addButtonClass?: string
@@ -478,6 +517,7 @@ const props = withDefaults(
showClear: false,
showLabel: true,
useFilterIcon: false,
applyImmediately: false,
showPreviewFilterIcon: false,
emptyOptionsLabel: 'No options available.',
emptySearchLabel: 'No options found.',
@@ -519,6 +559,18 @@ let pendingCategoryTimeout: ReturnType<typeof setTimeout> | null = null
let previousMousePosition: Point | null = null
let addMenuPositionRafId: number | null = null
function isDropdownFilterSectionHeader(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarSectionHeader {
return 'type' in item && item.type === 'section-header'
}
function isDropdownFilterOption(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarOption {
return !isDropdownFilterSectionHeader(item)
}
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
const source = isAddMenuOpen.value ? 'draft' : 'committed'
return props.categories.map((category) => {
@@ -569,15 +621,32 @@ const filteredActiveCategoryOptions = computed(() => {
return activeCategory.value.options
}
return activeCategory.value.options.filter((option) => {
if (option.label.toLowerCase().includes(query)) {
return true
const items: DropdownFilterBarItem[] = []
let pendingSectionHeader: DropdownFilterBarSectionHeader | null = null
for (const option of activeCategory.value.options) {
if (isDropdownFilterSectionHeader(option)) {
pendingSectionHeader = option
continue
}
if (option.value.toLowerCase().includes(query)) {
return true
const matches =
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
(option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false)
if (!matches) {
continue
}
return option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false
})
if (pendingSectionHeader) {
items.push(pendingSectionHeader)
pendingSectionHeader = null
}
items.push(option)
}
return items
})
const shouldVirtualizeActiveCategoryOptions = computed(
@@ -599,11 +668,13 @@ const {
})
const renderedVisibleActiveCategoryOptions = computed<VisibleDropdownFilterBarOption[]>(() =>
visibleActiveCategoryOptions.value.map((option, offset) => ({
option: {
...option,
selected: activeCategorySelectedValueSet.value.has(option.value),
},
visibleActiveCategoryOptions.value.map((item, offset) => ({
item: isDropdownFilterSectionHeader(item)
? item
: {
...item,
selected: activeCategorySelectedValueSet.value.has(item.value),
},
index: activeCategoryOptionsVisibleRange.value.start + offset,
})),
)
@@ -721,14 +792,14 @@ function areSelectedFiltersEqual(
}
function getOptionsWithSelectedValues(
options: DropdownFilterBarOption[],
options: DropdownFilterBarItem[],
selectedValues: string[],
): DropdownFilterBarOption[] {
): DropdownFilterBarItem[] {
if (selectedValues.length === 0) {
return options
}
const knownValues = new Set(options.map((option) => option.value))
const knownValues = new Set(options.filter(isDropdownFilterOption).map((option) => option.value))
const missingSelectedOptions = selectedValues
.filter((value) => !knownValues.has(value))
.map((value) => ({
@@ -748,15 +819,17 @@ function getCategorySyntheticValues(categoryKey: string): Set<string> {
return category ? getCategorySyntheticValueSet(category) : new Set()
}
function getVisiblePreviewOptions(
category: DropdownFilterBarCategory,
): MultiSelectOption<string>[] {
return category.options.map((option) => ({
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
})) as MultiSelectOption<string>[]
function getVisiblePreviewOptions(category: DropdownFilterBarCategory): MultiSelectItem<string>[] {
return category.options.map((option) =>
isDropdownFilterSectionHeader(option)
? option
: {
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
},
) as MultiSelectItem<string>[]
}
function getPreviewOptionLabel(
@@ -764,7 +837,7 @@ function getPreviewOptionLabel(
selectedValue: string,
): string | undefined {
return [...(category.syntheticOptions ?? []), ...category.options].find(
(option) => option.value === selectedValue,
(option) => isDropdownFilterOption(option) && option.value === selectedValue,
)?.label
}
@@ -797,6 +870,9 @@ function setSelectedValues(
if (isAddMenuOpen.value && activeCategoryKey.value === categoryKey) {
scheduleSubmenuPositionUpdate()
}
if (props.applyImmediately) {
emit('update:modelValue', nextFilters)
}
} else {
emit('update:modelValue', nextFilters)
}
@@ -950,6 +1026,70 @@ function toggleFilterOption(categoryKey: string, option: DropdownFilterBarOption
toggleFilterValue(categoryKey, option.value, !isFilterValueSelected(categoryKey, option.value))
}
function getActiveCategoryItemKey(item: RenderedDropdownFilterBarItem, index: number) {
return isDropdownFilterSectionHeader(item)
? (item.key ?? `${activeCategory.value?.key ?? 'category'}-section-${item.label}-${index}`)
: `${activeCategory.value?.key ?? 'category'}-${item.value}`
}
function getSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return []
}
const sectionHeaderIndex = category.options.findIndex((item) => item === sectionHeader)
if (sectionHeaderIndex === -1) {
return []
}
const options: DropdownFilterBarOption[] = []
for (let index = sectionHeaderIndex + 1; index < category.options.length; index += 1) {
const item = category.options[index]
if (!item || isDropdownFilterSectionHeader(item)) {
break
}
if (!item.disabled) {
options.push(item)
}
}
return options
}
function hasSelectableSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
return getSectionHeaderOptions(sectionHeader).length > 1
}
function areSectionHeaderOptionsSelected(sectionHeader: DropdownFilterBarSectionHeader) {
const options = getSectionHeaderOptions(sectionHeader)
return (
options.length > 0 &&
options.every((option) => activeCategorySelectedValueSet.value.has(option.value))
)
}
function toggleSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return
}
const options = getSectionHeaderOptions(sectionHeader)
if (options.length === 0) {
return
}
const optionValues = options.map((option) => option.value)
const optionValueSet = new Set(optionValues)
const currentValues = activeCategorySelectedValues.value
const nextValues = areSectionHeaderOptionsSelected(sectionHeader)
? currentValues.filter((value) => !optionValueSet.has(value))
: [...currentValues, ...optionValues.filter((value) => !currentValues.includes(value))]
setSelectedValues(category.key, nextValues, 'draft')
}
function getActiveCategoryOptionWrapperStyle(index: number): CSSProperties | undefined {
if (!shouldVirtualizeActiveCategoryOptions.value) {
return undefined
@@ -1076,9 +1216,13 @@ function getPreviewSelectedValues(categoryKey: string): string[] {
}
function setPreviewSelectedValues(categoryKey: string, values: string[]) {
const normalizedValues = normalizeSelectedValues(values)
previewSelectedValueDrafts.value = {
...previewSelectedValueDrafts.value,
[categoryKey]: normalizeSelectedValues(values),
[categoryKey]: normalizedValues,
}
if (props.applyImmediately) {
setSelectedValues(categoryKey, normalizedValues)
}
}
@@ -31,11 +31,15 @@ const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCo
const floatingActionBarId = Symbol('floating-action-bar')
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
() => pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)',
const leftOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)'),
)
const rightOffset = computed(
() => pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)',
const rightOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)'),
)
const barStyle = computed(() => ({
zIndex: zIndex.value,
@@ -2,6 +2,7 @@
<div class="joined-buttons">
<ButtonStyled :color="color" :size="size">
<button
v-tooltip="primaryTooltip"
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
:disabled="primaryDisabledResolved"
@click="handlePrimaryAction"
@@ -15,6 +16,7 @@
class="btn-dropdown-animation !w-10"
:options="dropdownOptions"
:disabled="dropdownDisabledResolved"
:tooltip="dropdownTooltip"
>
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
@@ -53,6 +55,8 @@ interface Props {
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
primaryTooltip?: string
dropdownTooltip?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -62,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
primaryDisabled: undefined,
dropdownDisabled: undefined,
primaryMuted: false,
primaryTooltip: undefined,
dropdownTooltip: undefined,
})
const primaryDisabledResolved = computed(() => props.primaryDisabled ?? props.disabled)
@@ -74,6 +74,7 @@
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
v-tooltip="leftButtonConfig.tooltip"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
@@ -84,6 +85,7 @@
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button
v-tooltip="rightButtonConfig.tooltip"
class="!shadow-none"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@@ -128,6 +130,7 @@ export interface StageButtonConfig {
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
loading?: boolean
tooltip?: string
iconClass?: string | null
buttonClass?: string | null
onClick?: () => void
@@ -28,6 +28,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:maxlength="maxlength"
:rows="rows"
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
@@ -55,6 +58,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:inputmode="inputmode"
:maxlength="maxlength"
:min="min"
@@ -124,6 +130,9 @@ const props = withDefaults(
id?: string
name?: string
autocomplete?: string
autocorrect?: 'on' | 'off'
autocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
spellcheck?: boolean
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
maxlength?: number
min?: number
+56 -2
View File
@@ -62,7 +62,56 @@
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<TransitionGroup
v-if="rowTransitionName && !virtualized"
:name="rowTransitionName"
tag="tbody"
>
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
</template>
</TransitionGroup>
<tbody v-else :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
@@ -83,7 +132,7 @@
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
@@ -169,6 +218,7 @@ const props = withDefaults(
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
rowTransitionName?: string
/**
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
@@ -269,6 +319,10 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
return rowIndex
}
function getRowClass(rowIndex: number): string {
return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function isSelected(row: T): boolean {
return selectedIdSet.value.has(getSelectionId(row))
}