mirror of
https://github.com/home-assistant/frontend.git
synced 2026-01-15 19:57:41 +00:00
Compare commits
2 Commits
ai-task-sh
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d241aa49a | ||
|
|
fece231faf |
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user