Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a82779e
update
jaredgibb Nov 13, 2025
7231ecf
commit
jaredgibb Nov 13, 2025
b7bdf63
Update wwElement_OptionsList.vue
jaredgibb Nov 13, 2025
f49095b
Update wwElement_OptionsList.vue
jaredgibb Nov 13, 2025
57f00d6
Fix comment syntax in ww-config.js
jaredgibb Nov 13, 2025
e706865
Add debug logging to OptionsList component
jaredgibb Nov 13, 2025
5d6464c
Refactor search watcher in OptionsList component
jaredgibb Nov 13, 2025
5eaad5f
Remove debug logging from OptionsList, add logs to Select
jaredgibb Nov 13, 2025
57f2e25
Update wwElement_Select.vue
jaredgibb Nov 13, 2025
f0cdb18
Add debug watcher for isOpen state changes
jaredgibb Nov 13, 2025
c18dd92
Update wwElement_Select.vue
jaredgibb Nov 13, 2025
a2bdb67
Add debug watchers for content and initialState
jaredgibb Nov 13, 2025
4ff31b8
Update wwElement_Select.vue
jaredgibb Nov 13, 2025
a43816d
Add isSorting flag to manage dropdown sorting state
jaredgibb Nov 13, 2025
f9d928b
Add debug logging to Option and Select components
jaredgibb Nov 17, 2025
d9b714b
Update wwElement_Option.vue
jaredgibb Nov 17, 2025
795872d
Update wwElement_Option.vue
jaredgibb Nov 17, 2025
9d81074
Add debug logging to options filtering and sorting
jaredgibb Nov 18, 2025
f4f611c
Add debug logging to search input handlers
jaredgibb Nov 18, 2025
447a461
Add files via upload
jaredgibb Nov 18, 2025
0bf0117
Merge branch 'main' of https://github.com/jaredgibb/ww-input-select
jaredgibb Nov 18, 2025
4283a5f
Update wwElement_OptionsList.vue
jaredgibb Nov 18, 2025
54ca2bb
Move search filtering logic to Search component
jaredgibb Nov 18, 2025
cde1e33
Remove debug console.log statements from components
jaredgibb Nov 18, 2025
d18382b
Emit per-item change event (changeOneItem)
jaredgibb Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ Note: chip properties for multiselect, and selected properties for single select
**_Events:_**

- change: Triggered when selection changes. Payload: { value: any }
- changeOneItem: Triggered when a single item is changed/clicked. Payload: { value: any }
- initValueChange: Triggered when initial value changes. Payload: { value: any }

**_Context:_**
Expand Down
15 changes: 12 additions & 3 deletions src/wwElement_Option.vue
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,19 @@ export default {
);

const handleClick = () => {
if (isSelected.value && canInteract.value && props.content.unselectOnClick) {
const unselectOnClick = props.content.unselectOnClick ?? false;
const selectOnClick = props.content.selectOnClick ?? true;
const sortSelectedToTop = props.content.sortSelectedToTop ?? false;

// Allow unselecting if either:
// 1. unselectOnClick is explicitly enabled, OR
// 2. sortSelectedToTop is enabled (natural UX for sorted items)
const canUnselect = unselectOnClick || sortSelectedToTop;

if (isSelected.value && canInteract.value && canUnselect) {
unselect();
focusFromOptionId(null);
} else if (!isSelected.value && canInteract.value && props.content.selectOnClick) {
} else if (!isSelected.value && canInteract.value && selectOnClick) {
updateValue(value.value);
focusFromOptionId(optionId);
focusSelectElement();
Expand Down Expand Up @@ -306,7 +315,7 @@ export default {
const unselect = () => {
if (canInteract.value) {
if (selectType.value === 'single') {
updateValue(null);
updateValue(null, value.value);
} else {
removeSpecificValue(value.value);
}
Expand Down
95 changes: 82 additions & 13 deletions src/wwElement_OptionsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@

<script>
import InputSelectOption from './wwElement_Option.vue';
import { ref, inject, computed, watch } from 'vue';
import { ref, inject, computed, watch, toValue } from 'vue';
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from 'vue-virtual-scroller';
import { useMemoize } from '@vueuse/core';
import { areValuesEqual } from './utils';
/* wwEditor:start */
import useEditorHint from './editor/useEditorHint';
/* wwEditor:end */
Expand Down Expand Up @@ -99,6 +100,9 @@ export default {
const searchState = inject('_wwSelect:searchState', ref(null));
const { updateSearch } = inject('_wwSelect:useSearch', {});
const registerOptionProperties = inject('_wwSelect:registerOptionProperties', () => {});
const selectedValue = inject('_wwSelect:value', ref(null));
const mappingValue = inject('_wwSelect:mappingValue', ref(null));
const isSorting = inject('_wwSelect:isSorting', ref(false));
const virtualScrollMinItemSize = computed(() => props.content.virtualScrollMinItemSize);
const virtualScrollBuffer = computed(() => props.content.virtualScrollBuffer);
const heavyMode = computed(() => props.content.heavyMode);
Expand Down Expand Up @@ -155,9 +159,66 @@ export default {
});
});

const { resolveMappingFormula } = wwLib.wwFormula.useFormula();

const filteredOptions = computed(() => {
if (!searchState.value || !searchState.value.value) return options.value;
let filtered = memoizedFilter(options.value, searchState.value.value);
let filtered = options.value;

// Apply search filter if active
if (searchState.value && searchState.value.value) {
filtered = memoizedFilter(options.value, searchState.value.value);
}

// Apply sorting if sortSelectedToTop is enabled
if (props.content.sortSelectedToTop && selectedValue.value != null) {
try {
isSorting.value = true;

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isSorting flag is being set within a computed property, which violates Vue's reactivity principles. Computed properties should be pure functions without side effects. Setting isSorting.value inside filteredOptions computed will cause it to run during every reactivity cycle, potentially triggering the setTimeout multiple times. This should be moved to a watcher or a method that is called when sorting is actually needed.

Copilot uses AI. Check for mistakes.

// Create a map of option values to avoid recalculating during sort
const optionValueMap = new Map();
filtered.forEach((option) => {
const isPrimitive = typeof option !== 'object' || option === null;
const value = isPrimitive
? option
: resolveMappingFormula(toValue(mappingValue.value), option) ?? option;
optionValueMap.set(option, value);
});

filtered = [...filtered].sort((a, b) => {
const aValue = optionValueMap.get(a);
const bValue = optionValueMap.get(b);

// Check if each option is selected
let aIsSelected, bIsSelected;

if (Array.isArray(selectedValue.value)) {
// Multiple selection mode
aIsSelected = selectedValue.value.some(v => areValuesEqual(v, aValue));
bIsSelected = selectedValue.value.some(v => areValuesEqual(v, bValue));
} else {
// Single selection mode
aIsSelected = areValuesEqual(selectedValue.value, aValue);
bIsSelected = areValuesEqual(selectedValue.value, bValue);
}

// Sort selected items to top
if (aIsSelected && !bIsSelected) return -1;
if (!aIsSelected && bIsSelected) return 1;
return 0; // Keep original order for items with same selection status
});

// Reset the flag after a brief delay to allow DOM to update
setTimeout(() => {
isSorting.value = false;
}, 100);
} catch (error) {
console.error('[OptionsList] Error during sorting:', error);
isSorting.value = false;
// Return unsorted on error
filtered = [...filtered];
}
}

return filtered;
});

Expand All @@ -166,21 +227,29 @@ export default {
// Handle primitive values properly - don't spread them as they become indexed objects
const isPrimitive = typeof item !== 'object' || item === null;
if (isPrimitive) {
// For primitives, create a simple object wrapper
return { value: item, id: `id_${index}` };
// For primitives, create a simple object wrapper with stable ID based on value
const stableId = `primitive_${JSON.stringify(item)}`;
return { value: item, id: stableId };
} else {
// For objects, use the existing spread logic
return { ...item, id: item.id ?? `id_${index}` };
// For objects, use existing id or create stable ID based on the object's value
const existingId = item.id;
if (existingId != null) {
return { ...item, id: existingId };
}
// Create stable ID based on object content to survive re-sorting
try {
const stableId = `obj_${JSON.stringify(item)}`;
return { ...item, id: stableId };
} catch {
// Fallback to index-based if JSON.stringify fails (circular refs, etc.)
return { ...item, id: `id_${index}` };
}
}
});
});

watch(filteredOptions, () => {
if (updateSearch) {
const searchMatches = searchState.value && searchState.value.value ? filteredOptions.value : [];
updateSearch({ ...searchState.value, searchMatches });
}
});
// searchFilteredCount is NOT used anymore - removed to prevent infinite loop
// The Search component handles updating searchMatches directly

// Styles
const scrollerStyle = computed(() => {
Expand Down
60 changes: 58 additions & 2 deletions src/wwElement_Search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export default {
'_wwSelect:useSearch',
{}
);
const rawData = inject('_wwSelect:rawData', ref([]));
const searchState = inject('_wwSelect:searchState', ref(null));

const searchElementRef = ref(null);
const searchElement = computed(() => searchElementRef.value);
const searchBy = computed(() => {
Expand All @@ -43,9 +46,60 @@ export default {
.map(item => JSON.parse(item.filter.replace(/'/g, '"')))
.flat();
});

const options = computed(() => {
const items = rawData.value;
return Array.isArray(items) ? items : [];
});

// Helper function to filter options based on search value
const filterOptions = (options, filterValue) => {
if (!filterValue) return options;

return options.filter(option => {
// Handle primitive values directly
const isPrimitive = typeof option !== 'object' || option === null;
if (isPrimitive) {
const normalizedOption = option
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
const normalizedFilter = filterValue
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
return normalizedOption.includes(normalizedFilter);
} else {
// For objects, use the existing search logic
const searchByFields = searchState.value?.searchBy?.length
? searchState.value?.searchBy
: Object.keys(option);
return searchByFields.some(key => {
const optionValue = option[key];
if (!optionValue) return false;
const normalizedOption = optionValue
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
const normalizedFilter = filterValue
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();

return normalizedOption.includes(normalizedFilter);
});
}
});
};

const debouncedUpdateSearch = debounce((value, searchBy) => {
if (updateSearch) updateSearch({ value, searchBy });
if (updateSearch) {
// Compute searchMatches here in the Search component
const searchMatches = value ? filterOptions(options.value, value) : [];
updateSearch({ value, searchBy, searchMatches });
}
}, 300);

const searchStyles = computed(() => {
Expand Down Expand Up @@ -102,7 +156,9 @@ export default {

onMounted(() => {
if (updateHasSearch) updateHasSearch(true);
if (updateSearch) updateSearch({ value: '', searchBy, searchMatches: [] });
if (updateSearch) {
updateSearch({ value: '', searchBy, searchMatches: [] });
}
});

onBeforeUnmount(() => {
Expand Down
41 changes: 26 additions & 15 deletions src/wwElement_Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export default {
const triggerWidth = ref(0);
const triggerHeight = ref(0);
const shouldCloseDropdown = ref(true);
const isSorting = ref(false);
const optionType = computed(() => props.content.optionType || 'text');
const mappingLabel = computed(() => props.content.mappingLabel);
const mappingIcon = computed(() => props.content.mappingIcon);
Expand Down Expand Up @@ -277,23 +278,21 @@ export default {
searchState.value = filter;
};

const updateValue = value => {
const updateValue = (value, oneItemValue) => {
if (selectType.value === 'single') {
// Check if value is an array
if (Array.isArray(value)) {
console.warn('Single select component received an array value. Only the first value will be used.');
value = value[0];
}
setValue(value);
emit('trigger-event', { name: 'change', event: { value } });
emitChangeEvents(value, { emitOneItem: true, oneItemValue: oneItemValue ?? value });
} else {
// Check if value is an array
if (!Array.isArray(value)) {
value = [value];
}
const valuesToApply = Array.isArray(value) ? value : [value];

const currentValue = Array.isArray(variableValue.value) ? [...variableValue.value] : [];
for (let iValue of value) {
for (let iValue of valuesToApply) {
// Find index using the utility function
const valueIndex = findValueIndex(currentValue, iValue);

Expand All @@ -307,7 +306,10 @@ export default {
}

setValue(currentValue);
emit('trigger-event', { name: 'change', event: { value: currentValue } });
emitChangeEvents(currentValue, {
emitOneItem: valuesToApply.length === 1,
oneItemValue: valuesToApply[0],
});
}

if (props.content.closeOnSelect) closeDropdown();
Expand Down Expand Up @@ -383,7 +385,7 @@ export default {

// Only emit change event if the value actually changed
if (valueChanged) {
emit('trigger-event', { name: 'change', event: { value: eventValue } });
emitChangeEvents(eventValue, { emitOneItem: true, oneItemValue: value });
}
};

Expand Down Expand Up @@ -411,7 +413,7 @@ export default {
setValue(currentValue);
}

emit('trigger-event', { name: 'change', event: { value: currentValue } });
emitChangeEvents(currentValue, { emitOneItem: true, oneItemValue: valueToRemove });

// Close dropdown if closeOnSelect is enabled, just like regular selection
if (props.content.closeOnSelect) {
Expand Down Expand Up @@ -480,18 +482,19 @@ export default {

function resetValue() {
setValue(initValue.value || null);
emit('trigger-event', { name: 'change', event: { value: initValue.value || null } });
emitChangeEvents(initValue.value || null);
}

function handleClickOutside(event) {
if (
closeOnClickOutside.value &&
const shouldClose = closeOnClickOutside.value &&
isOpen.value &&
!triggerElement.value.contains(event.target) &&
!dropdownElement.value.contains(event.target) &&
!dropdownElement.value?.contains(event.target) &&
!isEditing.value &&
Date.now() > lastTriggeredComponentAction.value + 400
) {
!isSorting.value &&
Date.now() > lastTriggeredComponentAction.value + 400;

if (shouldClose) {
closeDropdown();
}
}
Expand Down Expand Up @@ -656,6 +659,13 @@ export default {
}
});

function emitChangeEvents(value, { emitOneItem = false, oneItemValue } = {}) {
emit('trigger-event', { name: 'change', event: { value } });
if (emitOneItem) {
emit('trigger-event', { name: 'changeOneItem', event: { value: oneItemValue } });
}
}

const _options = computed(() => options.value?.map(({ optionId, ...option }) => option) || ref([])); // Hide optionId

const data = ref({
Expand Down Expand Up @@ -935,6 +945,7 @@ export default {
provide('_wwSelect:isDisabled', isDisabled);
provide('_wwSelect:isReadonly', isReadonly);
provide('_wwSelect:canUnselect', canUnselect);
provide('_wwSelect:isSorting', isSorting);
provide('_wwSelect:searchState', searchState);
provide('_wwSelect:optionProperties', optionProperties);
provide('_wwSelect:updateValue', updateValue);
Expand Down
Loading
Loading