You've already forked AstralRinth
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user