Style changes. Scrollbar improvments

This commit is contained in:
andrewwallacespeckle
2025-08-27 20:21:33 +01:00
parent e1bcbd6c04
commit bc58728b64
10 changed files with 249 additions and 66 deletions
@@ -1,5 +1,5 @@
<template>
<div class="px-3 pt-3">
<div class="px-3 py-2 flex items-center justify-between gap-3">
<FormSelectBase
name="filter-logic"
label="Filter Logic"
@@ -21,19 +21,33 @@
<span class="text-foreground text-body-2xs">{{ item.label }}</span>
</template>
</FormSelectBase>
<div class="flex items-center justify-end gap-1">
<Ghost class="h-4 w-4" />
<FormCheckbox
:model-value="ghostMode"
name="ghost-mode"
hide-label
size="sm"
@update:model-value="handleGhostModeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { FilterLogic } from '~/lib/viewer/helpers/filters/types'
import { FormSelectBase } from '@speckle/ui-components'
import { FormSelectBase, FormCheckbox } from '@speckle/ui-components'
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
import { Ghost } from 'lucide-vue-next'
import type { Optional } from '@speckle/shared'
defineProps<{
modelValue: FilterLogic
}>()
const { setFilterLogicAndUpdate } = useFilterUtilities()
const { setFilterLogicAndUpdate, setGhostModeAndUpdate, ghostMode } =
useFilterUtilities()
const filterLogicOptions = ref([
{ value: FilterLogic.All, label: 'Match all rules' },
@@ -50,4 +64,10 @@ const handleLogicChange = (
setFilterLogicAndUpdate(option.value)
}
}
const handleGhostModeChange = (enabled: Optional<string | true> | string[]) => {
// Convert the checkbox value to boolean
const isEnabled = Array.isArray(enabled) ? enabled.length > 0 : !!enabled
setGhostModeAndUpdate(isEnabled)
}
</script>
@@ -1,5 +1,5 @@
<template>
<ViewerLayoutSidePanel>
<ViewerLayoutSidePanel disable-scrollbar>
<template #title>Filters</template>
<template #actions>
<div class="flex gap-x-0.5 items-center">
@@ -34,9 +34,9 @@
<!-- Active Filters Section -->
<div
v-if="propertyFilters.length > 0"
class="flex-1 overflow-y-scroll simple-scrollbar"
class="flex-1 overflow-y-auto simple-scrollbar"
>
<div class="flex flex-col gap-3 p-3">
<div class="flex flex-col gap-3 p-3 pt-0">
<ViewerFiltersFilterCard
v-for="filter in propertyFilters"
:key="filter.id"
@@ -98,19 +98,26 @@ const relevantFilters = computed(() => {
})
const propertySelectOptions = computed((): PropertySelectOption[] => {
const allOptions: PropertySelectOption[] = relevantFilters.value.map((filter) => {
const pathParts = filter.key.split('.')
const propertyName = pathParts[pathParts.length - 1] // Last part (e.g., "name")
const parentPath = pathParts.slice(0, -1).join('.') // Everything except last part (e.g., "ab")
// Get keys of already added filters
const existingFilterKeys = new Set(
propertyFilters.value.map((f) => f.filter?.key).filter(Boolean)
)
return {
value: filter.key,
label: propertyName, // Clean property name for main display
parentPath, // Full path without the property name
type: filter.type,
hasParent: parentPath.length > 0
}
})
const allOptions: PropertySelectOption[] = relevantFilters.value
.filter((filter) => !existingFilterKeys.has(filter.key)) // Exclude already added filters
.map((filter) => {
const pathParts = filter.key.split('.')
const propertyName = pathParts[pathParts.length - 1] // Last part (e.g., "name")
const parentPath = pathParts.slice(0, -1).join('.') // Everything except last part (e.g., "ab")
return {
value: filter.key,
label: propertyName, // Clean property name for main display
parentPath, // Full path without the property name
type: filter.type,
hasParent: parentPath.length > 0
}
})
// Sort: root properties first, then grouped by parent
const sortedOptions = allOptions.sort((a, b) => {
@@ -1,5 +1,32 @@
<template>
<div>
<div
v-if="shouldShowAppliedSection"
class="bg-highlight-1 rounded-md mx-2 my-1 p-1"
>
<div class="flex flex-wrap gap-1">
<span
v-for="value in displayedSelectedValues"
:key="value"
class="inline-flex items-center gap-1 px-2 py-0.5 bg-highlight-3 text-foreground text-body-3xs rounded"
>
{{ value }}
<button
class="text-foreground-2 hover:text-foreground"
@click="() => toggleValue(value)"
>
×
</button>
</span>
<span
v-if="remainingCount > 0"
class="inline-flex items-center px-2 py-0.5 bg-highlight-3 text-foreground-2 text-body-3xs rounded"
>
and {{ remainingCount }} more
</span>
</div>
</div>
<ViewerFiltersFilterStringSelectAll
:selected-count="selectedCount"
:total-count="filteredValues.length"
@@ -29,6 +56,11 @@
:is-selected="isValueSelected(value)"
:count="getValueCount(value)"
:color="getValueColor(value)"
:is-default-selected="
isStringFilter(filter) &&
filter.isDefaultAllSelected &&
isValueSelected(value)
"
@toggle="() => toggleValue(value)"
/>
</div>
@@ -89,6 +121,8 @@ const selectAll = (selected: boolean) => {
// Deselect all - set to empty array in one operation
updateActiveFilterValues(props.filter.id, [])
}
// Note: default state clearing is now handled in updateActiveFilterValues
}
// Get available values from the filter
@@ -111,6 +145,35 @@ const filteredValues = computed(() => {
)
})
// Get selected values
const selectedValues = computed(() => {
return props.filter.selectedValues || []
})
// Check if we should show the applied section (not when select all is active)
const shouldShowAppliedSection = computed(() => {
return selectedValues.value.length > 0 && !isSelectAllActive.value
})
// Check if select all is active (when all available values are selected)
const isSelectAllActive = computed(() => {
return (
selectedValues.value.length === availableValues.value.length &&
availableValues.value.length > 0
)
})
// Limit displayed values to 8 items
const maxDisplayedItems = 5
const displayedSelectedValues = computed(() => {
return selectedValues.value.slice(0, maxDisplayedItems)
})
// Count of remaining items not displayed
const remainingCount = computed(() => {
return Math.max(0, selectedValues.value.length - maxDisplayedItems)
})
// Select all logic
const selectedCount = computed(() => {
return filteredValues.value.filter((value) => isValueSelected(value)).length
@@ -118,7 +181,7 @@ const selectedCount = computed(() => {
// Virtual list setup
const itemHeight = 28 // Height of each checkbox item in pixels
const maxHeight = 144 // 36 * 4px (h-36 equivalent)
const maxHeight = 210
const containerHeight = computed(() => {
const contentHeight = filteredValues.value.length * itemHeight
@@ -14,8 +14,8 @@
: 'bg-foundation border-highlight-3 hover:border-foreground-2'
]"
>
<Minus v-if="areAllValuesSelected" class="h-3 w-3" />
<Check v-else-if="areSomeValuesSelected" class="h-3 w-3" />
<Check v-if="areAllValuesSelected" class="h-3 w-3" />
<Minus v-else-if="areSomeValuesSelected" class="h-3 w-3" />
</div>
<span class="text-foreground ml-px">Select all</span>
<div class="text-foreground-2 text-body-3xs ml-1">
@@ -4,6 +4,7 @@
<div class="px-1">
<div
class="flex items-center justify-between gap-2 text-body-3xs py-0.5 px-2 hover:bg-highlight-1 rounded cursor-pointer"
:class="{ 'opacity-50': isDefaultSelected }"
@click="$emit('toggle')"
>
<div class="flex items-center min-w-0">
@@ -41,6 +42,7 @@ defineProps<{
isSelected: boolean
count: number
color?: string | null
isDefaultSelected?: boolean
}>()
defineEmits<{
@@ -19,13 +19,14 @@
top: 0,
left: 0,
width: '100%',
height: `${itemHeight}px`,
transform: `translateY(${index * itemHeight}px)`
height: `${getItemHeight(property)}px`,
transform: `translateY(${getItemOffset(index)}px)`
}"
>
<div class="px-1">
<button
class="w-full h-full py-1.5 px-2 text-foreground rounded hover:bg-highlight-1 text-left flex items-center gap-2"
class="w-full h-full px-2 text-foreground rounded hover:bg-highlight-1 text-left flex items-center gap-2"
:class="!property.parentPath ? 'py-1.5' : 'py-1'"
@click="$emit('selectProperty', property.value)"
>
<Hash v-if="property.type === 'number'" class="h-3 w-3" />
@@ -34,8 +35,11 @@
<div class="text-body-2xs font-medium text-foreground truncate">
{{ property.label }}
</div>
<div class="text-body-3xs text-foreground-2 truncate -mt-0.5">
{{ property.parentPath || '-' }}
<div
v-if="property.parentPath"
class="text-body-3xs text-foreground-2 truncate -mt-0.5"
>
{{ property.parentPath }}
</div>
</div>
</button>
@@ -82,17 +86,39 @@ const filteredOptions = computed(() => {
)
})
// Virtual list setup
const itemHeight = 42
// Virtual list setup with dynamic heights
const smallItemHeight = 28 // Height for items without parent
const largeItemHeight = 38 // Height for items with parent
const maxHeight = 300 // Maximum height for the container
// Helper function to get height for a specific property
const getItemHeight = (property: PropertyOption) => {
return property.parentPath && property.parentPath !== '-'
? largeItemHeight
: smallItemHeight
}
// Helper function to calculate offset for a specific index
const getItemOffset = (index: number) => {
let offset = 0
for (let i = 0; i < index; i++) {
const property = filteredOptions.value[i]
if (property) {
offset += getItemHeight(property)
}
}
return offset
}
const containerHeight = computed(() => {
const contentHeight = filteredOptions.value.length * itemHeight
const contentHeight = filteredOptions.value.reduce((total, property) => {
return total + getItemHeight(property)
}, 0)
return `${Math.min(contentHeight, maxHeight)}px`
})
const { list, containerProps } = useVirtualList(filteredOptions, {
itemHeight,
itemHeight: largeItemHeight, // Use larger height as base for virtual list calculations
overscan: 5
})
</script>
@@ -1,5 +1,10 @@
<template>
<div :class="['flex flex-col h-full', disableScrollbar ? '' : 'overflow-hidden']">
<div
:class="[
'flex flex-col h-full max-h-[calc(100dvh-5rem)]',
disableScrollbar ? '' : 'overflow-hidden'
]"
>
<div
class="flex shrink-0 justify-between items-center border-b border-outline-3 h-10 pl-4 pr-2.5"
>
@@ -45,6 +45,7 @@ function createFilteringDataStore() {
const dataSourcesMap: Ref<Record<string, DataSource>> = ref({})
const dataSources = computed(() => Object.values(dataSourcesMap.value))
const currentFilterLogic = ref<FilterLogic>(FilterLogic.All)
const ghostMode = ref<boolean>(true) // Default to ghosting enabled
const dataSlices: Ref<DataSlice[]> = ref([])
const extractNestedProperties = (
@@ -330,6 +331,10 @@ function createFilteringDataStore() {
currentFilterLogic.value = logic
}
const setGhostMode = (enabled: boolean) => {
ghostMode.value = enabled
}
const updateViewer = (
instance: Viewer,
filters: {
@@ -340,32 +345,42 @@ function createFilteringDataStore() {
const objectIds = getFinalObjectIds()
const filteringExtension = instance.getExtension(FilteringExtension)
// Always clear existing filters first to prevent accumulation
filteringExtension.resetFilters()
// Check if there are any applied filters
const hasAppliedFilters = filters.propertyFilters.value.some(
(filter) => filter.isApplied
)
if (objectIds.length > 0) {
// Clear existing property-filter isolation first to prevent accumulation
filteringExtension.resetFilters()
// Isolate the matching objects (ghost parameter controls transparency vs hiding)
filteringExtension.isolateObjects(
objectIds,
'property-filters',
true,
ghostMode.value
)
} else if (hasAppliedFilters) {
// When no objects match but filters are applied, isolate a fake object ID to ghost/hide everything
// This provides better visual feedback than completely hiding everything
filteringExtension.isolateObjects(
['no-match-ghost-all'],
'property-filters',
true,
ghostMode.value
)
}
// If no applied filters and no objects match, do nothing - return to unfiltered state
filteringExtension.isolateObjects(objectIds, 'property-filters', true, true)
} else {
// Preserve color filter when clearing isolation
const currentColorFilterId = filters.activeColorFilterId.value
let activeColorFilter = null
if (currentColorFilterId) {
const activeFilter = filters.propertyFilters.value.find(
(f: FilterData) => f.id === currentColorFilterId
)
if (activeFilter?.filter) {
activeColorFilter = activeFilter.filter
}
}
// Reset all filters (including isolation)
filteringExtension.resetFilters()
// Restore color filter if it was active
if (activeColorFilter && currentColorFilterId) {
filteringExtension.setColorFilter(activeColorFilter)
filters.activeColorFilterId.value = currentColorFilterId
// Restore color filter if it was active
const currentColorFilterId = filters.activeColorFilterId.value
if (currentColorFilterId) {
const activeFilter = filters.propertyFilters.value.find(
(f: FilterData) => f.id === currentColorFilterId
)
if (activeFilter?.filter) {
filteringExtension.setColorFilter(activeFilter.filter)
}
}
}
@@ -383,6 +398,8 @@ function createFilteringDataStore() {
clearPropertyIndexCache,
setFilterLogic,
currentFilterLogic,
setGhostMode,
ghostMode,
dataSlices
}
}
@@ -515,7 +532,7 @@ export function useFilterUtilities(
* Creates a properly typed FilterData object from PropertyInfo
*/
const createFilterData = (params: CreateFilterParams): FilterData => {
const { filter, id } = params
const { filter, id, availableValues } = params
if (isNumericPropertyInfo(filter)) {
return {
@@ -534,11 +551,12 @@ export function useFilterUtilities(
return {
id,
isApplied: true,
selectedValues: [],
selectedValues: [...availableValues], // Select all values by default
condition: StringFilterCondition.Is,
type: FilterType.String,
filter: filter as StringPropertyInfo,
numericRange: { min: 0, max: 100 } // Default range for consistency
numericRange: { min: 0, max: 100 }, // Default range for consistency
isDefaultAllSelected: true // Track that this is the initial "all selected" state
} satisfies StringFilterData
}
}
@@ -607,6 +625,12 @@ export function useFilterUtilities(
const filter = filters.propertyFilters.value.find((f) => f.id === filterId)
if (filter) {
filter.selectedValues = [...values]
// Clear the default all-selected state when user updates values
if (!isNumericFilter(filter) && filter.isDefaultAllSelected) {
filter.isDefaultAllSelected = false
}
updateDataStoreSlices()
}
}
@@ -647,6 +671,11 @@ export function useFilterUtilities(
updateDataStoreSlices()
}
const setGhostModeAndUpdate = (enabled: boolean) => {
dataStore.setGhostMode(enabled)
updateDataStoreSlices()
}
/**
* Updates data store slices based on current filter state (optimized)
*/
@@ -681,7 +710,7 @@ export function useFilterUtilities(
}
dataStore.dataSlices.value.push(slice)
}
// Handle string filters with selected values
// Handle string filters - only create slice if filter is enabled AND has selected values
else if (
!isNumericFilter(filter) &&
filter.isApplied &&
@@ -722,14 +751,21 @@ export function useFilterUtilities(
const index = filter.selectedValues.indexOf(value)
const wasSelected = index > -1
if (wasSelected) {
filter.selectedValues.splice(index, 1)
// Special behavior for default all-selected state: first click selects only that item
if (!isNumericFilter(filter) && filter.isDefaultAllSelected) {
filter.selectedValues = [value] // Select only this item
filter.isDefaultAllSelected = false // Clear default state
} else {
filter.selectedValues.push(value)
// Normal toggle behavior
if (wasSelected) {
filter.selectedValues.splice(index, 1)
} else {
filter.selectedValues.push(value)
}
}
// Ensure filter is marked as applied when values are selected
filter.isApplied = filter.selectedValues.length > 0
// Don't change isApplied here - that's controlled by the visibility toggle
// isApplied represents whether the filter is enabled, not whether it has values
updateDataStoreSlices()
}
@@ -743,6 +779,23 @@ export function useFilterUtilities(
return filter ? filter.selectedValues.includes(value) : false
}
/**
* Checks if a filter has content (selected values or numeric range)
*/
const filterHasContent = (filter: FilterData): boolean => {
if (isNumericFilter(filter)) {
// For numeric filters, check if range is different from default
const defaultMin = filter.filter.min
const defaultMax = filter.filter.max
return (
filter.numericRange.min !== defaultMin || filter.numericRange.max !== defaultMax
)
} else {
// For string filters, check if any values are selected
return filter.selectedValues.length > 0
}
}
/**
* Gets all currently applied filters
*/
@@ -1140,9 +1193,11 @@ export function useFilterUtilities(
updateFilterCondition,
setFilterLogicAndUpdate,
setGhostModeAndUpdate,
updateDataStoreSlices,
toggleActiveFilterValue,
isActiveFilterValueSelected,
filterHasContent,
getAppliedFilters,
resetFilters,
resetExplode,
@@ -1172,6 +1227,8 @@ export function useFilterUtilities(
// Filter logic
setFilterLogic: dataStore.setFilterLogic,
currentFilterLogic: dataStore.currentFilterLogic,
getFinalObjectIds: dataStore.getFinalObjectIds
getFinalObjectIds: dataStore.getFinalObjectIds,
// Ghost mode
ghostMode: dataStore.ghostMode
}
}
@@ -1109,7 +1109,9 @@ function setupInterfaceState(
if (hiddenObjectIds.value.length) return true
if (
propertyFilters.value.some(
(filter) => filter.selectedValues && filter.selectedValues.length > 0
(filter) =>
filter.isApplied ||
(filter.selectedValues && filter.selectedValues.length > 0)
)
)
return true
@@ -77,6 +77,7 @@ export type NumericFilterData = BaseFilterData & {
export type StringFilterData = BaseFilterData & {
type: FilterType.String
filter: StringPropertyInfo
isDefaultAllSelected?: boolean // Track initial "all selected" state
}
// Union type for all filter data