Compare commits

...

2 Commits

Author SHA1 Message Date
uptimeZERO_
1d241aa49a Truncate long menu item labels in the sidebar (#29005) 2026-01-15 15:41:48 +00:00
Pegasus
fece231faf fix: restrict to exact match for data table (#28853) 2026-01-15 15:50:15 +01:00
3 changed files with 183 additions and 55 deletions

View File

@@ -1,9 +1,9 @@
import { expose } from "comlink";
import Fuse, { type FuseOptionKey } from "fuse.js";
import type { FuseOptionKey, IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { multiTermSearch } from "../../resources/fuseMultiTerm";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -11,46 +11,159 @@ import type {
SortingDirection,
} from "./ha-data-table";
const getSearchKeys = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => {
const searchKeys = new Set<string>();
interface FilterKeyConfig {
key: string;
filterKey?: string;
}
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
column.filterKey
? `${column.valueColumn || key}.${column.filterKey}`
: key
);
}
});
return Array.from(searchKeys);
const getFilterKeys = memoizeOne(
(columns: SortableColumnContainer): FilterKeyConfig[] =>
Object.entries(columns)
.filter(([, column]) => column.filterable)
.map(([key, column]) => ({
key: column.valueColumn || key,
filterKey: column.filterKey,
}))
);
const getSearchableValue = (
row: DataTableRowData,
{ key, filterKey }: FilterKeyConfig
): string => {
let value = row[key];
if (value == null) {
return "";
}
);
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
if (filterKey && typeof value === "object" && !Array.isArray(value)) {
value = value[filterKey];
if (value == null) {
return "";
}
}
if (Array.isArray(value)) {
const stringValues = value
.filter((item) => item != null && typeof item !== "object")
.map(String);
return stripDiacritics(stringValues.join(" ").toLowerCase());
}
return stripDiacritics(String(value).toLowerCase());
};
/** Filters data using exact substring matching (all terms must match). */
const filterDataExact = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
if (terms.length === 1) {
const term = terms[0];
return data.filter((row) =>
filterKeys.some((config) =>
getSearchableValue(row, config).includes(term)
)
);
}
return data.filter((row) => {
const searchString = filterKeys
.map((config) => getSearchableValue(row, config))
.join(" ");
return terms.every((term) => searchString.includes(term));
});
};
const FUZZY_OPTIONS: IFuseOptions<DataTableRowData> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.2, // Stricter than default 0.3
minMatchCharLength: 2,
ignoreLocation: true,
shouldSort: false,
};
interface FuseKeyConfig {
name: string | string[];
getFn: (row: DataTableRowData) => string;
}
/** Filters data using fuzzy matching with Fuse.js (all terms must match). */
const filterDataFuzzy = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
// Build Fuse.js search keys from filter keys
const fuseKeys: FuseKeyConfig[] = filterKeys.map((config) => ({
name: config.filterKey ? [config.key, config.filterKey] : config.key,
getFn: (row: DataTableRowData) => getSearchableValue(row, config),
}));
// Find minimum term length to adjust minMatchCharLength
const minTermLength = Math.min(...terms.map((t) => t.length));
const minMatchCharLength = Math.min(minTermLength, 2);
const fuse = new Fuse<DataTableRowData>(data, {
...FUZZY_OPTIONS,
keys: fuseKeys as FuseOptionKey<DataTableRowData>[],
minMatchCharLength,
});
// For single term, simple search
if (terms.length === 1) {
return fuse.search(terms[0]).map((r) => r.item);
}
// For multiple terms, all must match (AND logic)
const expression = {
$and: terms.map((term) => ({
$or: fuseKeys.map((key) => ({
$path: Array.isArray(key.name) ? key.name : [key.name],
$val: term,
})),
})),
};
return fuse.search(expression).map((r) => r.item);
};
/**
* Filters data with exact match priority and fuzzy fallback.
* - First tries exact substring matching
* - If exact matches found, returns only those
* - If no exact matches, falls back to fuzzy search with strict scoring
*/
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
filter = stripDiacritics(filter.toLowerCase());
): DataTableRowData[] => {
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
if (filter === "") {
if (!normalizedFilter) {
return data;
}
const keys = getSearchKeys(columns);
const filterKeys = getFilterKeys(columns);
const index = fuseIndex(data, keys);
if (!filterKeys.length) {
return data;
}
return multiTermSearch<DataTableRowData>(data, filter, keys, index, {
threshold: 0.2, // reduce fuzzy matches in data tables
});
const terms = normalizedFilter.split(/\s+/);
// First, try exact substring matching
const exactMatches = filterDataExact(data, filterKeys, terms);
if (exactMatches.length > 0) {
return exactMatches;
}
// No exact matches, fall back to fuzzy search
return filterDataFuzzy(data, filterKeys, terms);
};
const sortData = (

View File

@@ -589,10 +589,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
// On keypresses on the listbox, we're going to ignore mouse enter events
// for 100ms so that we ignore it when pressing down arrow scrolls the
// sidebar causing the mouse to hover a new icon
if (
this.alwaysExpand ||
new Date().getTime() < this._recentKeydownActiveUntil
) {
if (new Date().getTime() < this._recentKeydownActiveUntil) {
return;
}
if (this._mouseLeaveTimeout) {
@@ -612,7 +609,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _listboxFocusIn(ev) {
if (this.alwaysExpand || ev.target.localName !== "ha-md-list-item") {
if (ev.target.localName !== "ha-md-list-item") {
return;
}
this._showTooltip(ev.target);
@@ -652,6 +649,14 @@ class HaSidebar extends SubscribeMixin(LitElement) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = undefined;
}
const itemText = item.querySelector(".item-text") as HTMLElement | null;
if (this.hasAttribute("expanded") && itemText) {
const isTruncated = itemText.scrollWidth > itemText.clientWidth;
if (!isTruncated) {
this._hideTooltip();
return;
}
}
const tooltip = this._tooltip;
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!;
const listbox = [...allListbox].find((lb) => lb.contains(item));
@@ -662,9 +667,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
tooltip.innerText = (
item.querySelector(".item-text") as HTMLElement
).innerText;
tooltip.innerText = itemText?.innerText ?? "";
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`;
@@ -846,6 +849,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
:host([expanded]) ha-md-list-item .item-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.divider {
@@ -913,7 +919,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
position: absolute;
opacity: 0.9;
border-radius: var(--ha-border-radius-sm);
white-space: nowrap;
max-width: calc(var(--ha-space-20) * 3);
white-space: normal;
overflow-wrap: break-word;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: var(--ha-space-1);

View File

@@ -175,24 +175,31 @@ export class HaConfigLovelaceDashboards extends LitElement {
template: narrow
? undefined
: (dashboard) => html`
${dashboard.title}
${dashboard.default
? html`
<ha-svg-icon
.id="default-icon-${dashboard.title}"
style="padding-left: 10px; padding-inline-start: 10px; padding-inline-end: initial; direction: var(--direction);"
.path=${mdiHomeCircleOutline}
></ha-svg-icon>
<ha-tooltip
.for="default-icon-${dashboard.title}"
placement="right"
>
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.default_dashboard`
)}
</ha-tooltip>
`
: nothing}
<span
style="display:flex; align-items:center; gap: var(--ha-space-2); min-width:0; width:100%;"
>
<span
style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;"
>${dashboard.title}</span
>
${dashboard.default
? html`
<ha-svg-icon
.id="default-icon-${dashboard.title}"
style="flex-shrink:0;"
.path=${mdiHomeCircleOutline}
></ha-svg-icon>
<ha-tooltip
.for="default-icon-${dashboard.title}"
placement="right"
>
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.default_dashboard`
)}
</ha-tooltip>
`
: nothing}
</span>
`,
},
};