mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-22 21:53:28 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5a2dbe30f | ||
|
|
22b6d7c20e | ||
|
|
9285ef824f | ||
|
|
3e99aa1817 | ||
|
|
5ec6f5a08d | ||
|
|
6272ee6eed | ||
|
|
debc9685fb | ||
|
|
fd47b899b4 | ||
|
|
d0e8ff1184 | ||
|
|
7aad9aa344 |
No files matched your search
@@ -2,8 +2,23 @@
|
||||
const URL_ATTRIBUTES = [
|
||||
["a", "href"],
|
||||
["img", "src"],
|
||||
["source", "src"],
|
||||
] as const;
|
||||
|
||||
// The markdown worker drops a srcset unless every part between commas and
|
||||
// spaces is a safe URL or a descriptor, so a plain split is enough: the srcset
|
||||
// is written back from the rewritten URLs.
|
||||
const splitSrcsetCandidates = (srcset: string) =>
|
||||
srcset
|
||||
.split(",")
|
||||
.map((candidate) => candidate.trim().split(/\s+/))
|
||||
.filter(([url]) => url);
|
||||
|
||||
const rewriteSrcset = (srcset: string, rewrite: (url: string) => string) =>
|
||||
splitSrcsetCandidates(srcset)
|
||||
.map(([url, ...descriptors]) => [rewrite(url), ...descriptors].join(" "))
|
||||
.join(", ");
|
||||
|
||||
/**
|
||||
* Rewrite the address of every link and image in a piece of HTML.
|
||||
*
|
||||
@@ -29,5 +44,13 @@ export const rewriteHtmlUrls = (
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite each address in a <picture> source like an image
|
||||
for (const element of template.content.querySelectorAll("source[srcset]")) {
|
||||
element.setAttribute(
|
||||
"srcset",
|
||||
rewriteSrcset(element.getAttribute("srcset")!, rewrite)
|
||||
);
|
||||
}
|
||||
|
||||
return template.innerHTML;
|
||||
};
|
||||
@@ -79,6 +79,8 @@ const DEFAULT_TOOLTIP_TRIGGER_ON = "mousemove|click|mousewheel";
|
||||
const ZOOM_SLIDER_BOTTOM = 4;
|
||||
// Room the plot gives up at its bottom while the zoom slider shows
|
||||
const ZOOM_SLIDER_SPACE = ZOOM_SLIDER_BOTTOM + ZOOM_SLIDER_HEIGHT + 8;
|
||||
// Grid bottom that just fits the x-axis labels, as in line charts
|
||||
const X_AXIS_LABEL_SPACE = 20;
|
||||
const ZOOM_RESET_SIZE = 24;
|
||||
const ZOOM_RESET_OFFSET = ZOOM_RESET_SIZE + 4;
|
||||
// A narrower drag selection is a sloppy click rather than a zoom
|
||||
@@ -524,7 +526,17 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
const room = this._showZoomSlider();
|
||||
if (this.chart && room !== this._zoomSliderRoom) {
|
||||
this._zoomSliderRoom = room;
|
||||
this.chart.setOption({ grid: this._getGridOption() });
|
||||
if (this.growForZoomSlider) {
|
||||
// The host grows by the room the grid gives up, so apply both in one
|
||||
// render to keep the plot still instead of shrinking and regrowing it
|
||||
this.chart.setOption(
|
||||
{ grid: this._getGridOption() },
|
||||
{ lazyUpdate: true }
|
||||
);
|
||||
this.chart.resize();
|
||||
} else {
|
||||
this.chart.setOption({ grid: this._getGridOption() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,7 +551,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
style=${styleMap({
|
||||
height:
|
||||
showZoomSlider && this.growForZoomSlider && this.height
|
||||
? `calc(${this.height} + ${ZOOM_SLIDER_SPACE}px)`
|
||||
? `calc(${this.height} + ${this._getZoomSliderSpace()}px)`
|
||||
: this.height,
|
||||
})}
|
||||
>
|
||||
@@ -1051,6 +1063,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
.start=${this._zoomRange[0]}
|
||||
.end=${this._zoomRange[1]}
|
||||
@zoom-slider-changed=${this._handleZoomSliderChanged}
|
||||
@zoom-slider-reset=${this._handleZoomSliderReset}
|
||||
@pointerdown=${this._holdZoomSlider}
|
||||
@focusin=${this._holdZoomSlider}
|
||||
@pointerup=${this._releaseZoomSlider}
|
||||
@@ -1066,6 +1079,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._setZoomWindow(ev.detail.start, ev.detail.end);
|
||||
}
|
||||
|
||||
// A held slider stays at the full range, so release it to hide it
|
||||
private _handleZoomSliderReset() {
|
||||
this._zoomSliderHeld = false;
|
||||
this._handleZoomReset();
|
||||
}
|
||||
|
||||
private _holdZoomSlider() {
|
||||
this._zoomSliderHeld = true;
|
||||
}
|
||||
@@ -1084,8 +1103,25 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
return grid;
|
||||
}
|
||||
const [first, ...rest] = ensureArray(grid);
|
||||
const bottom = typeof first.bottom === "number" ? first.bottom : 0;
|
||||
return [{ ...first, bottom: bottom + ZOOM_SLIDER_SPACE }, ...rest];
|
||||
return [
|
||||
{ ...first, bottom: this._getGridBottom() + this._getZoomSliderSpace() },
|
||||
...rest,
|
||||
];
|
||||
}
|
||||
|
||||
private _getGridBottom() {
|
||||
const bottom =
|
||||
this.options?.grid && ensureArray(this.options.grid)[0].bottom;
|
||||
return typeof bottom === "number" ? bottom : 0;
|
||||
}
|
||||
|
||||
// Charts that grow for the slider let it take their padding below the
|
||||
// x-axis labels, so it sits as close to the labels as in other charts
|
||||
private _getZoomSliderSpace() {
|
||||
return this.growForZoomSlider
|
||||
? ZOOM_SLIDER_SPACE -
|
||||
Math.max(0, this._getGridBottom() - X_AXIS_LABEL_SPACE)
|
||||
: ZOOM_SLIDER_SPACE;
|
||||
}
|
||||
|
||||
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
|
||||
@@ -1330,6 +1366,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
},
|
||||
},
|
||||
timeAxis: {
|
||||
axisPointer: {
|
||||
lineStyle: { color: style.getPropertyValue("--info-color") },
|
||||
},
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: { color: style.getPropertyValue("--divider-color") },
|
||||
|
||||
@@ -20,6 +20,10 @@ const THUMB_INSET = (ZOOM_SLIDER_HEIGHT - THUMB_SIZE) / 2;
|
||||
const RANGE_MIN_WIDTH = 2 * (THUMB_INSET + THUMB_SIZE) + ZOOM_SLIDER_HEIGHT;
|
||||
// The smallest visible range the thumbs can be dragged to, in percent
|
||||
const MIN_RANGE = 1;
|
||||
// A press this soon after a tap's release shows the full range again
|
||||
const DOUBLE_PRESS_TIME = 300;
|
||||
// A press that moves further than this is a drag, not a tap
|
||||
const TAP_MAX_DISTANCE = 4;
|
||||
|
||||
type SliderPart = "start" | "end" | "range";
|
||||
|
||||
@@ -33,6 +37,10 @@ export class HaChartZoomSlider extends LitElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
|
||||
private _pressX?: number;
|
||||
|
||||
private _lastTapTime?: number;
|
||||
|
||||
private _drag?: {
|
||||
pointerId: number;
|
||||
part: SliderPart;
|
||||
@@ -54,6 +62,8 @@ export class HaChartZoomSlider extends LitElement {
|
||||
<div
|
||||
class="track"
|
||||
@pointerdown=${this._handleTrackPointerDown}
|
||||
@pointerup=${this._handleTrackPointerUp}
|
||||
@pointercancel=${this._handleTrackPointerUp}
|
||||
@selectstart=${preventDefault}
|
||||
>
|
||||
<div
|
||||
@@ -122,11 +132,29 @@ export class HaChartZoomSlider extends LitElement {
|
||||
return 100 / Math.max(this.clientWidth - RANGE_MIN_WIDTH, 1);
|
||||
}
|
||||
|
||||
// Pressing the track beside the visible range centers the range there
|
||||
// Pressing the track beside the visible range centers the range there.
|
||||
// Double tapping anywhere on the slider resets it to the full range; this
|
||||
// times taps itself, as touch browsers do not reliably fire dblclick.
|
||||
private _handleTrackPointerDown(
|
||||
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
if (ev.button !== 0 || ev.target !== ev.currentTarget) {
|
||||
if (ev.button !== 0 || !ev.isPrimary) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this._lastTapTime !== undefined &&
|
||||
ev.timeStamp - this._lastTapTime < DOUBLE_PRESS_TIME
|
||||
) {
|
||||
this._lastTapTime = undefined;
|
||||
this._pressX = undefined;
|
||||
this._drag = undefined;
|
||||
// The press ends here, so the chart does not keep holding the slider open
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "zoom-slider-reset");
|
||||
return;
|
||||
}
|
||||
this._pressX = ev.clientX;
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
const position = (ev.offsetX - RANGE_MIN_WIDTH / 2) * this._getScale();
|
||||
@@ -137,6 +165,20 @@ export class HaChartZoomSlider extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
// Only a press released where it started counts as a tap, so a quick drag
|
||||
// followed by another press does not reset the zoom
|
||||
private _handleTrackPointerUp(ev: PointerEvent) {
|
||||
if (!ev.isPrimary) {
|
||||
return;
|
||||
}
|
||||
const tapped =
|
||||
ev.type === "pointerup" &&
|
||||
this._pressX !== undefined &&
|
||||
Math.abs(ev.clientX - this._pressX) <= TAP_MAX_DISTANCE;
|
||||
this._lastTapTime = tapped ? ev.timeStamp : undefined;
|
||||
this._pressX = undefined;
|
||||
}
|
||||
|
||||
private _handlePointerDown(
|
||||
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
@@ -287,6 +329,7 @@ export class HaChartZoomSlider extends LitElement {
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"zoom-slider-changed": { start: number; end: number };
|
||||
"zoom-slider-reset": undefined;
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-chart-zoom-slider": HaChartZoomSlider;
|
||||
|
||||
@@ -230,6 +230,13 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
// The item tooltip draws no axis line, so mark the pointed time as
|
||||
// line charts do, also where a tap opens the tooltip
|
||||
axisPointer: {
|
||||
show: true,
|
||||
triggerTooltip: false,
|
||||
label: { show: false },
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "category",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* @element ha-action-bar
|
||||
*
|
||||
* @summary Bar with actions, placed below a scrolling list or content area.
|
||||
*
|
||||
* @slot - Actions of the bar, e.g. `ha-button` elements.
|
||||
*
|
||||
* @cssprop --ha-action-bar-border-top - Top border of the bar, set it to match the border of the parent. Defaults to `var(--ha-border-width-sm) solid var(--divider-color)`.
|
||||
*/
|
||||
@customElement("ha-action-bar")
|
||||
export class HaActionBar extends LitElement {
|
||||
protected render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-3);
|
||||
border-top: var(
|
||||
--ha-action-bar-border-top,
|
||||
var(--ha-border-width-sm) solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-action-bar": HaActionBar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mdiContentPaste } from "@mdi/js";
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { HaActionBar } from "./ha-action-bar";
|
||||
import "./ha-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* @element ha-paste-bar
|
||||
*
|
||||
* @summary Action bar with a paste button, placed below a scrolling list, e.g.
|
||||
* in the add automation element, create card and create badge dialogs.
|
||||
* Only render it when there is something on the clipboard.
|
||||
*
|
||||
* @attr label - Localized label of the paste button, e.g. "Paste action".
|
||||
*
|
||||
* @cssprop --ha-action-bar-border-top - Top border of the bar. Defaults to `var(--ha-border-width-sm) solid var(--divider-color)`.
|
||||
*
|
||||
* @fires paste-element - Fired when the paste button is clicked.
|
||||
*/
|
||||
@customElement("ha-paste-bar")
|
||||
export class HaPasteBar extends HaActionBar {
|
||||
@property() public label = "";
|
||||
|
||||
protected render() {
|
||||
return html`<ha-button appearance="filled" size="s" @click=${this._paste}>
|
||||
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
|
||||
${this.label}
|
||||
</ha-button>`;
|
||||
}
|
||||
|
||||
private _paste() {
|
||||
fireEvent(this, "paste-element");
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-paste-bar": HaPasteBar;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"paste-element": undefined;
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,16 @@ const initRouting = () => {
|
||||
// Get api from network.
|
||||
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
|
||||
|
||||
// Signed URLs (such as media files under /media/ with an authSig) differ on
|
||||
// every request, so caching them never produces a hit. With the catch-all
|
||||
// route below, a photo slideshow would store one full-size image per slide
|
||||
// until the browser's storage quota runs out.
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
url.origin === location.origin && url.searchParams.has("authSig"),
|
||||
new NetworkOnly()
|
||||
);
|
||||
|
||||
// Get manifest and onboarding from network.
|
||||
registerRoute(/\/(?:manifest\.json|onboarding\.html)/, new NetworkOnly());
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-button-prev";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-paste-bar";
|
||||
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
|
||||
import "../../../components/ha-section-title";
|
||||
import "../../../components/ha-service-icon";
|
||||
@@ -123,7 +124,6 @@ import { haStyleScrollbar } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import "./add-automation-element/ha-automation-add-element-paste";
|
||||
import "./add-automation-element/ha-automation-add-from-target";
|
||||
import "./add-automation-element/ha-automation-add-items";
|
||||
import "./add-automation-element/ha-automation-add-search";
|
||||
@@ -863,12 +863,13 @@ class DialogAddAutomationElement
|
||||
`
|
||||
}
|
||||
${
|
||||
showPasteFooter
|
||||
? html`<ha-automation-add-element-paste
|
||||
.automationElementType=${automationElementType}
|
||||
.clipboardItem=${this._params!.clipboardItem}
|
||||
showPasteFooter && this._params!.clipboardItem
|
||||
? html`<ha-paste-bar
|
||||
.label=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.paste_element`
|
||||
)}
|
||||
@paste-element=${this._paste}
|
||||
></ha-automation-add-element-paste>`
|
||||
></ha-paste-bar>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
@@ -922,12 +923,13 @@ class DialogAddAutomationElement
|
||||
@value-changed=${this._selected}
|
||||
>
|
||||
${
|
||||
this._tab === "blocks"
|
||||
? html`<ha-automation-add-element-paste
|
||||
.automationElementType=${automationElementType}
|
||||
.clipboardItem=${this._params!.clipboardItem}
|
||||
this._tab === "blocks" && this._params!.clipboardItem
|
||||
? html`<ha-paste-bar
|
||||
.label=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.paste_element`
|
||||
)}
|
||||
@paste-element=${this._paste}
|
||||
></ha-automation-add-element-paste>`
|
||||
></ha-paste-bar>`
|
||||
: nothing
|
||||
}
|
||||
</ha-automation-add-items>
|
||||
@@ -2552,13 +2554,10 @@ class DialogAddAutomationElement
|
||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
||||
}
|
||||
|
||||
ha-bottom-sheet
|
||||
.list-column:has(> ha-automation-add-element-paste[clipboard-item]) {
|
||||
ha-bottom-sheet .list-column:has(> ha-paste-bar) {
|
||||
margin-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
ha-bottom-sheet
|
||||
ha-automation-add-items
|
||||
> ha-automation-add-element-paste {
|
||||
ha-bottom-sheet ha-automation-add-items > ha-paste-bar {
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
|
||||
@@ -2726,13 +2725,10 @@ class DialogAddAutomationElement
|
||||
.groups {
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
.list-column:has(> ha-automation-add-element-paste[clipboard-item])
|
||||
> .groups {
|
||||
.list-column:has(> ha-paste-bar) > .groups {
|
||||
padding-bottom: var(--ha-space-3);
|
||||
}
|
||||
ha-automation-add-items:has(
|
||||
> ha-automation-add-element-paste[clipboard-item]
|
||||
) {
|
||||
ha-automation-add-items:has(> ha-paste-bar) {
|
||||
--ha-automation-add-items-padding-bottom: var(--ha-space-3);
|
||||
}
|
||||
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiContentPaste } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
|
||||
|
||||
/**
|
||||
* @element ha-automation-add-element-paste
|
||||
*
|
||||
* @summary Paste bar at the bottom of the add automation element dialog's list pane.
|
||||
* Place it below the scrolling list.
|
||||
*
|
||||
* @attr clipboard-item - Type of the element on the clipboard. Nothing is shown when empty.
|
||||
*
|
||||
* @fires paste-element - Fired when the paste button is clicked.
|
||||
*/
|
||||
@customElement("ha-automation-add-element-paste")
|
||||
export class HaAutomationAddElementPaste extends LitElement {
|
||||
@property({ attribute: "clipboard-item", reflect: true })
|
||||
public clipboardItem?: string;
|
||||
|
||||
@property({ attribute: "automation-element-type" })
|
||||
public automationElementType!: AddAutomationElementDialogParams["type"];
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
protected render() {
|
||||
if (!this.clipboardItem) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-button appearance="filled" size="s" @click=${this._paste}>
|
||||
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.automation.editor.${this.automationElementType}s.paste_element`
|
||||
)}
|
||||
</ha-button>`;
|
||||
}
|
||||
|
||||
private _paste() {
|
||||
fireEvent(this, "paste-element");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-3);
|
||||
border-top: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
}
|
||||
:host(:not([clipboard-item])) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-add-element-paste": HaAutomationAddElementPaste;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"paste-element": undefined;
|
||||
}
|
||||
}
|
||||
@@ -12,21 +12,23 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { currentPath } from "../../../common/url/current-path";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon-button";
|
||||
@@ -59,6 +61,10 @@ import { showToast } from "../../../util/toast";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
|
||||
|
||||
const BARE_PATH = "/config/blueprint";
|
||||
const DASHBOARD_PATH = `${BARE_PATH}/dashboard`;
|
||||
const IMPORT_PATH = `${DASHBOARD_PATH}/import`;
|
||||
|
||||
type BlueprintMetaDataPath = BlueprintMetaData & {
|
||||
path: string;
|
||||
error: boolean;
|
||||
@@ -97,11 +103,11 @@ class HaBlueprintOverview extends LitElement {
|
||||
Blueprints
|
||||
>;
|
||||
|
||||
@storage({ key: "blueprint-table-sort", state: false, subscribe: false })
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
|
||||
@storage({ key: "blueprint-table-grouping", state: false, subscribe: false })
|
||||
private _activeGrouping?: string;
|
||||
private _tableSettings = new DataTableSettingsController(this, {
|
||||
search: "blueprint-table-search",
|
||||
sorting: "blueprint-table-sort",
|
||||
grouping: "blueprint-table-grouping",
|
||||
});
|
||||
|
||||
@storage({
|
||||
key: "blueprint-table-collapsed",
|
||||
@@ -124,15 +130,6 @@ class HaBlueprintOverview extends LitElement {
|
||||
})
|
||||
private _activeHiddenColumns?: string[];
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "blueprint-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
|
||||
@state() private _usageCounts: Record<string, number> = {};
|
||||
|
||||
private _usageCountRequest = 0;
|
||||
@@ -209,6 +206,7 @@ class HaBlueprintOverview extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "type",
|
||||
},
|
||||
path: {
|
||||
title: localize("ui.panel.config.blueprint.overview.headers.file_name"),
|
||||
@@ -319,16 +317,38 @@ class HaBlueprintOverview extends LitElement {
|
||||
})
|
||||
);
|
||||
|
||||
protected async firstUpdated(changedProps: PropertyValues<this>) {
|
||||
public connectedCallback() {
|
||||
// Move to the dashboard path before the URL controllers connect, they
|
||||
// keep the path they connected on. The router only gets there from the
|
||||
// bare path after this, and the import path is left for the dashboard.
|
||||
// Reads the URL, as a cached config panel reconnects this page with its
|
||||
// old route on the way to another page.
|
||||
const path = currentPath().replace(/\/$/, "");
|
||||
const importUrl =
|
||||
path === IMPORT_PATH
|
||||
? (extractSearchParam("blueprint_url") ?? "")
|
||||
: undefined;
|
||||
|
||||
if (path === BARE_PATH || importUrl !== undefined) {
|
||||
const search = importUrl === undefined ? mainWindow.location.search : "";
|
||||
replaceCurrentUrl(
|
||||
__DEMO__
|
||||
? `${mainWindow.location.pathname}${search}#${DASHBOARD_PATH}`
|
||||
: `${DASHBOARD_PATH}${search}`
|
||||
);
|
||||
fireEvent(mainWindow, "location-changed", { replace: true });
|
||||
}
|
||||
|
||||
super.connectedCallback();
|
||||
|
||||
if (importUrl) {
|
||||
this._addBlueprint(importUrl);
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadUsageCounts();
|
||||
if (this.route.path === "/import") {
|
||||
const url = extractSearchParam("blueprint_url");
|
||||
await navigate("/config/blueprint/dashboard", { replace: true });
|
||||
if (url) {
|
||||
this._addBlueprint(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
@@ -388,17 +408,18 @@ class HaBlueprintOverview extends LitElement {
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
</ha-button>
|
||||
</div>`}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
sync-url
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
@@ -789,22 +810,10 @@ class HaBlueprintOverview extends LitElement {
|
||||
});
|
||||
};
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
this._activeCollapsed = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleColumnsChanged(ev: CustomEvent) {
|
||||
this._activeColumnOrder = ev.detail.columnOrder;
|
||||
this._activeHiddenColumns = ev.detail.hiddenColumns;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { mdiPowerPlug } from "@mdi/js";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../../../../common/decorators/consume";
|
||||
import { QueryParamsController } from "../../../../../common/controllers/query-params-controller";
|
||||
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
@@ -17,6 +19,7 @@ import "../../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import "../../../../../components/ha-relative-time";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import { configEntriesContext } from "../../../../../data/context";
|
||||
import type {
|
||||
LoRaWANDevice,
|
||||
LoRaWANConnection,
|
||||
@@ -29,6 +32,7 @@ import "../../../../../layouts/hass-tabs-subpage-data-table";
|
||||
import { panelIsReady } from "../../../../../layouts/panel-ready";
|
||||
import { loadVirtualizer } from "../../../../../resources/virtualizer";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
import "./lorawan-connection-link";
|
||||
import { connectionLabels } from "./connection-labels";
|
||||
|
||||
@@ -36,6 +40,7 @@ interface DeviceRow extends LoRaWANDevice, DataTableRowData {
|
||||
id: string;
|
||||
rssi: number | null;
|
||||
connection_title: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
@customElement("lorawan-devices")
|
||||
@@ -65,6 +70,10 @@ export class LoRaWANDevices extends LitElement {
|
||||
@state() private _error = false;
|
||||
@state() private _connections: LoRaWANConnection[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: configEntriesContext, subscribe: true })
|
||||
private _configEntries?: ContextType<typeof configEntriesContext>;
|
||||
|
||||
private _unsubscribe?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _connectionLabels = memoizeOne(connectionLabels);
|
||||
@@ -73,18 +82,30 @@ export class LoRaWANDevices extends LitElement {
|
||||
(
|
||||
devices: LoRaWANDevice[] | undefined,
|
||||
connection: string | null,
|
||||
labels: Map<string, string>
|
||||
): DeviceRow[] =>
|
||||
(devices ?? [])
|
||||
labels: Map<string, string>,
|
||||
registryDevices: HomeAssistant["devices"],
|
||||
configEntries: ContextType<typeof configEntriesContext> | undefined
|
||||
): DeviceRow[] => {
|
||||
const entryDomains = new Map(
|
||||
(configEntries ?? []).map((entry) => [entry.entry_id, entry.domain])
|
||||
);
|
||||
return (devices ?? [])
|
||||
.filter(
|
||||
(device) => !connection || device.connection.entry_id === connection
|
||||
)
|
||||
.map((device) => ({
|
||||
...device,
|
||||
id: `${device.connection.entry_id}:${device.dev_eui}`,
|
||||
rssi: strongestLoRaWANReception(device.receptions)?.rssi ?? null,
|
||||
connection_title: labels.get(device.connection.entry_id)!,
|
||||
}))
|
||||
.map((device) => {
|
||||
const entryId =
|
||||
device.device_id &&
|
||||
registryDevices[device.device_id]?.primary_config_entry;
|
||||
return {
|
||||
...device,
|
||||
id: `${device.connection.entry_id}:${device.dev_eui}`,
|
||||
rssi: strongestLoRaWANReception(device.receptions)?.rssi ?? null,
|
||||
connection_title: labels.get(device.connection.entry_id)!,
|
||||
domain: entryId ? entryDomains.get(entryId) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -126,8 +147,32 @@ export class LoRaWANDevices extends LitElement {
|
||||
private _columns = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
narrow: boolean
|
||||
narrow: boolean,
|
||||
darkMode: boolean
|
||||
): DataTableColumnContainer<DeviceRow> => ({
|
||||
icon: {
|
||||
title: "",
|
||||
label: localize("ui.panel.config.lorawan.icon"),
|
||||
type: "icon",
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (device) =>
|
||||
device.domain
|
||||
? html`<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: device.domain,
|
||||
type: "icon",
|
||||
darkOptimized: darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: "",
|
||||
},
|
||||
name: {
|
||||
title: localize("ui.panel.config.lorawan.device"),
|
||||
main: true,
|
||||
@@ -233,8 +278,18 @@ export class LoRaWANDevices extends LitElement {
|
||||
.isWide=${this.isWide}
|
||||
.route=${this.route}
|
||||
.tabs=${this._tabs}
|
||||
.columns=${this._columns(this.hass.localize, this.narrow)}
|
||||
.data=${this._rows(this._devices, this._connectionId, labels)}
|
||||
.columns=${this._columns(
|
||||
this.hass.localize,
|
||||
this.narrow,
|
||||
this.hass.themes.darkMode
|
||||
)}
|
||||
.data=${this._rows(
|
||||
this._devices,
|
||||
this._connectionId,
|
||||
labels,
|
||||
this.hass.devices,
|
||||
this._configEntries
|
||||
)}
|
||||
.loading=${this._devices === undefined && !this._error}
|
||||
.loadError=${this._error}
|
||||
.noDataText=${this.hass.localize("ui.panel.config.lorawan.no_devices")}
|
||||
@@ -296,11 +351,6 @@ export class LoRaWANDevices extends LitElement {
|
||||
ha-select {
|
||||
margin: var(--ha-space-4);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
hass-tabs-subpage-data-table {
|
||||
--data-table-row-height: 132px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { DataTableSettingsController } from "../../../../../common/controllers/data-table-settings-controller";
|
||||
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
@@ -51,6 +52,8 @@ export class ZHAGroupsDashboard extends LitElement {
|
||||
|
||||
private _firstUpdatedCalled = false;
|
||||
|
||||
private _tableSettings = new DataTableSettingsController(this, {});
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hass && this._firstUpdatedCalled) {
|
||||
@@ -126,9 +129,12 @@ export class ZHAGroupsDashboard extends LitElement {
|
||||
: undefined
|
||||
}
|
||||
@retry-load=${this._retryFetchGroups}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
@row-click=${this._handleRowClicked}
|
||||
clickable
|
||||
has-fab
|
||||
sync-url
|
||||
>
|
||||
<ha-button href="/config/zha/group-add" slot="fab" size="l">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
|
||||
@@ -76,14 +76,9 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
return this.hass.auth.external?.config.canWriteTag;
|
||||
}
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "tags-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
private _tableSettings = new DataTableSettingsController(this, {
|
||||
search: "tags-table-search",
|
||||
});
|
||||
|
||||
private _columns = memoizeOne((localize: LocalizeFunc) => {
|
||||
const columns: DataTableColumnContainer<TagRowData> = {
|
||||
@@ -99,6 +94,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
title: localize("ui.panel.config.tag.headers.name"),
|
||||
main: true,
|
||||
sortable: true,
|
||||
urlName: "name",
|
||||
filterable: true,
|
||||
flex: 2,
|
||||
},
|
||||
@@ -111,6 +107,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
last_scanned_datetime: {
|
||||
title: localize("ui.panel.config.tag.headers.last_scanned"),
|
||||
sortable: true,
|
||||
urlName: "last_scanned",
|
||||
direction: "desc",
|
||||
template: (tag) => html`
|
||||
${
|
||||
@@ -218,8 +215,9 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
: undefined
|
||||
}
|
||||
@retry-load=${this._retryFetchTags}
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
sync-url
|
||||
selectable
|
||||
.selected=${this._selected.length}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
@@ -417,10 +415,6 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -346,6 +346,7 @@ class HaPanelHistory extends LitElement {
|
||||
.start=${this._zoomRange[0]}
|
||||
.end=${this._zoomRange[1]}
|
||||
@zoom-slider-changed=${this._zoomSliderChanged}
|
||||
@zoom-slider-reset=${this._resetZoom}
|
||||
></ha-chart-zoom-slider>
|
||||
</ha-card>`
|
||||
: nothing
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { mdiClose, mdiContentPaste } from "@mdi/js";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-paste-bar";
|
||||
import "../../../../components/ha-tab-group";
|
||||
import "../../../../components/ha-tab-group-tab";
|
||||
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
|
||||
@@ -165,12 +164,12 @@ export class HuiCreateDialogBadge
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="paste-bar">
|
||||
<ha-button appearance="filled" size="s" @click=${this._paste}>
|
||||
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.lovelace.editor.badge_picker.paste_badge")}
|
||||
</ha-button>
|
||||
</div>`;
|
||||
return html`<ha-paste-bar
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.badge_picker.paste_badge"
|
||||
)}
|
||||
@paste-element=${this._paste}
|
||||
></ha-paste-bar>`;
|
||||
}
|
||||
|
||||
private _ignoreKeydown(ev: KeyboardEvent) {
|
||||
@@ -200,24 +199,17 @@ export class HuiCreateDialogBadge
|
||||
ha-dialog::part(body) {
|
||||
overflow: hidden;
|
||||
}
|
||||
.paste-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-3);
|
||||
border-top: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-dialog {
|
||||
--safe-area-inset-bottom: 0px;
|
||||
}
|
||||
.paste-bar {
|
||||
ha-paste-bar {
|
||||
padding-bottom: max(
|
||||
var(--dialog-safe-area-inset-bottom),
|
||||
var(--ha-space-3)
|
||||
);
|
||||
}
|
||||
hui-badge-picker:not(:has(> .paste-bar)) {
|
||||
hui-badge-picker:not(:has(> ha-paste-bar)) {
|
||||
--ha-badge-picker-content-padding-bottom: var(
|
||||
--dialog-safe-area-inset-bottom
|
||||
);
|
||||
@@ -227,7 +219,7 @@ export class HuiCreateDialogBadge
|
||||
--dialog-safe-area-inset-bottom
|
||||
);
|
||||
}
|
||||
hui-badge-suggestion-picker:not(:has(> .paste-bar)) {
|
||||
hui-badge-suggestion-picker:not(:has(> ha-paste-bar)) {
|
||||
--ha-suggestion-picker-tree-padding-bottom: var(
|
||||
--dialog-safe-area-inset-bottom
|
||||
);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { mdiClose, mdiContentPaste } from "@mdi/js";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-paste-bar";
|
||||
import "../../../../components/ha-tab-group";
|
||||
import "../../../../components/ha-tab-group-tab";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
@@ -187,12 +186,12 @@ export class HuiCreateDialogCard
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="paste-bar">
|
||||
<ha-button appearance="filled" size="s" @click=${this._paste}>
|
||||
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.lovelace.editor.cardpicker.paste_card")}
|
||||
</ha-button>
|
||||
</div>`;
|
||||
return html`<ha-paste-bar
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.paste_card"
|
||||
)}
|
||||
@paste-element=${this._paste}
|
||||
></ha-paste-bar>`;
|
||||
}
|
||||
|
||||
private _ignoreKeydown(ev: KeyboardEvent) {
|
||||
@@ -222,24 +221,17 @@ export class HuiCreateDialogCard
|
||||
ha-dialog::part(body) {
|
||||
overflow: hidden;
|
||||
}
|
||||
.paste-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-3);
|
||||
border-top: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-dialog {
|
||||
--safe-area-inset-bottom: 0px;
|
||||
}
|
||||
.paste-bar {
|
||||
ha-paste-bar {
|
||||
padding-bottom: max(
|
||||
var(--dialog-safe-area-inset-bottom),
|
||||
var(--ha-space-3)
|
||||
);
|
||||
}
|
||||
hui-card-picker:not(:has(> .paste-bar)) {
|
||||
hui-card-picker:not(:has(> ha-paste-bar)) {
|
||||
--ha-card-picker-content-padding-bottom: var(
|
||||
--dialog-safe-area-inset-bottom
|
||||
);
|
||||
@@ -249,7 +241,7 @@ export class HuiCreateDialogCard
|
||||
--dialog-safe-area-inset-bottom
|
||||
);
|
||||
}
|
||||
hui-suggestion-picker:not(:has(> .paste-bar)) {
|
||||
hui-suggestion-picker:not(:has(> ha-paste-bar)) {
|
||||
--ha-suggestion-picker-tree-padding-bottom: var(
|
||||
--dialog-safe-area-inset-bottom
|
||||
);
|
||||
|
||||
@@ -32,6 +32,9 @@ declare global {
|
||||
const VALID_PATH_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
const INTEGER_REGEX = /^[0-9]+$/;
|
||||
|
||||
const MAX_COLUMNS_PRESETS = [1, 2, 3, 4] as const;
|
||||
const MAX_COLUMNS_CUSTOM = "custom";
|
||||
|
||||
@customElement("hui-view-editor")
|
||||
export class HuiViewEditor extends LitElement {
|
||||
@consumeLocalize()
|
||||
@@ -43,6 +46,13 @@ export class HuiViewEditor extends LitElement {
|
||||
|
||||
@state() private _error: Record<string, string> | undefined;
|
||||
|
||||
// Once the custom field is used, it keeps its own value apart from the
|
||||
// config: a first digit matching a preset does not switch the list, and a
|
||||
// cleared or invalid number stays as typed.
|
||||
@state() private _customMaxColumns?: boolean;
|
||||
|
||||
@state() private _customMaxColumnsDraft?: number;
|
||||
|
||||
private _suggestedPath = false;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
@@ -101,13 +111,37 @@ export class HuiViewEditor extends LitElement {
|
||||
schema: [
|
||||
{
|
||||
name: "max_columns",
|
||||
default: DEFAULT_MAX_COLUMNS,
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: [
|
||||
...MAX_COLUMNS_PRESETS.map((count) => ({
|
||||
value: String(count),
|
||||
label: localize(
|
||||
count === DEFAULT_MAX_COLUMNS
|
||||
? "ui.panel.lovelace.editor.edit_view.max_columns_option_default"
|
||||
: "ui.panel.lovelace.editor.edit_view.max_columns_option",
|
||||
{ count }
|
||||
),
|
||||
})),
|
||||
{
|
||||
value: MAX_COLUMNS_CUSTOM,
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.edit_view.max_columns_custom_option"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max_columns_custom",
|
||||
visible: { field: "max_columns", value: MAX_COLUMNS_CUSTOM },
|
||||
selector: {
|
||||
number: {
|
||||
min: 1,
|
||||
max: 10,
|
||||
mode: "slider",
|
||||
slider_ticks: true,
|
||||
mode: "box",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -132,6 +166,16 @@ export class HuiViewEditor extends LitElement {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private get _maxColumns(): number {
|
||||
return this._config.max_columns ?? DEFAULT_MAX_COLUMNS;
|
||||
}
|
||||
|
||||
private get _isCustomMaxColumns(): boolean {
|
||||
return (
|
||||
this._customMaxColumns ?? this._maxColumns > MAX_COLUMNS_PRESETS.length
|
||||
);
|
||||
}
|
||||
|
||||
get _type(): string {
|
||||
return getViewType(this._config);
|
||||
}
|
||||
@@ -139,6 +183,12 @@ export class HuiViewEditor extends LitElement {
|
||||
protected render() {
|
||||
const data = {
|
||||
...this._config,
|
||||
max_columns: this._isCustomMaxColumns
|
||||
? MAX_COLUMNS_CUSTOM
|
||||
: String(this._maxColumns),
|
||||
max_columns_custom: this._customMaxColumns
|
||||
? this._customMaxColumnsDraft
|
||||
: this._maxColumns,
|
||||
type: this._type,
|
||||
theme:
|
||||
this._config.theme?.toLowerCase() === "backend-selected"
|
||||
@@ -160,7 +210,26 @@ export class HuiViewEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
const config = ev.detail.value as LovelaceViewConfig;
|
||||
const {
|
||||
max_columns: maxColumnsChoice,
|
||||
max_columns_custom: maxColumnsCustom,
|
||||
...rest
|
||||
} = ev.detail.value as Omit<LovelaceViewConfig, "max_columns"> & {
|
||||
max_columns: string;
|
||||
max_columns_custom?: number;
|
||||
};
|
||||
const config: LovelaceViewConfig = rest;
|
||||
|
||||
this._customMaxColumns = maxColumnsChoice === MAX_COLUMNS_CUSTOM;
|
||||
this._customMaxColumnsDraft = maxColumnsCustom;
|
||||
const maxColumns = this._customMaxColumns
|
||||
? maxColumnsCustom
|
||||
: Number(maxColumnsChoice);
|
||||
if (maxColumns && maxColumns >= 1 && maxColumns !== this._maxColumns) {
|
||||
config.max_columns = maxColumns;
|
||||
} else if (this._config.max_columns !== undefined) {
|
||||
config.max_columns = this._config.max_columns;
|
||||
}
|
||||
|
||||
if (config.type !== SECTIONS_VIEW_LAYOUT) {
|
||||
delete config.max_columns;
|
||||
@@ -211,6 +280,7 @@ export class HuiViewEditor extends LitElement {
|
||||
case "show_icon_and_title":
|
||||
case "subview":
|
||||
case "max_columns":
|
||||
case "max_columns_custom":
|
||||
case "dense_section_placement":
|
||||
case "top_margin":
|
||||
case "section_specifics":
|
||||
@@ -231,6 +301,7 @@ export class HuiViewEditor extends LitElement {
|
||||
case "path":
|
||||
case "show_icon_and_title":
|
||||
case "subview":
|
||||
case "max_columns":
|
||||
case "dense_section_placement":
|
||||
case "top_margin":
|
||||
return this._localize(
|
||||
|
||||
@@ -109,6 +109,21 @@ const scriptsTableParams = {
|
||||
blueprint: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The blueprints table reads these from the link
|
||||
const blueprintsTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The tags table reads these from the link
|
||||
const tagsTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
export const getMyRedirects = (): Redirects => ({
|
||||
application_credentials: {
|
||||
redirect: "/config/application_credentials",
|
||||
@@ -334,6 +349,7 @@ export const getMyRedirects = (): Redirects => ({
|
||||
blueprints: {
|
||||
component: "blueprint",
|
||||
redirect: "/config/blueprint/dashboard",
|
||||
params: blueprintsTableParams,
|
||||
},
|
||||
automations: {
|
||||
component: "automation",
|
||||
@@ -363,6 +379,7 @@ export const getMyRedirects = (): Redirects => ({
|
||||
tags: {
|
||||
component: "tag",
|
||||
redirect: "/config/tags",
|
||||
params: tagsTableParams,
|
||||
},
|
||||
voice_assistants: {
|
||||
redirect: "/config/voice-assistants",
|
||||
|
||||
@@ -2,11 +2,46 @@ import { expose } from "comlink";
|
||||
import type { MarkedOptions } from "marked";
|
||||
import { marked } from "marked";
|
||||
import type { IWhiteList } from "xss";
|
||||
import { filterXSS, getDefaultWhiteList } from "xss";
|
||||
import {
|
||||
escapeAttrValue,
|
||||
filterXSS,
|
||||
friendlyAttrValue,
|
||||
getDefaultWhiteList,
|
||||
safeAttrValue,
|
||||
} from "xss";
|
||||
|
||||
let whiteListNormal: IWhiteList | undefined;
|
||||
let whiteListSvg: IWhiteList | undefined;
|
||||
|
||||
// The CSS filter is only used for `style` attributes
|
||||
const cssFilter = { process: (css: string) => css };
|
||||
|
||||
// xss only checks the protocol of `src` and `href`, so check every URL in
|
||||
// `srcset` the same way. Returns the attribute to render, or "" to drop it.
|
||||
const sanitizeSrcset = (value: string): string => {
|
||||
// Decode entities like xss does, and `&` so query strings keep working
|
||||
const decoded = friendlyAttrValue(value).replace(/&/g, "&");
|
||||
// Split at every comma and space. That is stricter than the browser (which
|
||||
// allows commas inside a URL), so every URL the browser reads starts with
|
||||
// one of these parts.
|
||||
const allowed = decoded.split(/[\s,]+/).every(
|
||||
(part) =>
|
||||
!part ||
|
||||
// A width or density descriptor such as `800w` or `2x`
|
||||
/^\d+(?:\.\d+)?[wx]$/.test(part) ||
|
||||
// `safeAttrValue` decodes again, so a part with entities left over
|
||||
// (`&#58;` leaves `:`) would be checked as text the browser
|
||||
// never sees. Drop it instead.
|
||||
(friendlyAttrValue(part) === part &&
|
||||
safeAttrValue("img", "src", part, cssFilter) !== "")
|
||||
);
|
||||
// xss would write the value back with `&` unescaped, so the browser would
|
||||
// decode it a second time. Escape it so the browser reads what was checked.
|
||||
return allowed
|
||||
? `srcset="${escapeAttrValue(decoded.replace(/&/g, "&"))}"`
|
||||
: "";
|
||||
};
|
||||
|
||||
const renderMarkdown = async (
|
||||
content: string,
|
||||
markedOptions: MarkedOptions,
|
||||
@@ -21,6 +56,9 @@ const renderMarkdown = async (
|
||||
...getDefaultWhiteList(),
|
||||
table: [...(getDefaultWhiteList().table ?? []), "role"],
|
||||
input: ["type", "disabled", "checked"],
|
||||
// Allows <picture>, and <video>/<audio> that have <source> children.
|
||||
picture: [],
|
||||
source: ["src", "srcset", "sizes", "media", "type"],
|
||||
"ha-icon": ["icon"],
|
||||
"ha-svg-icon": ["path"],
|
||||
"ha-alert": ["alert-type", "title"],
|
||||
@@ -91,6 +129,9 @@ const renderMarkdown = async (
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (tag === "source" && name === "srcset") {
|
||||
return sanitizeSrcset(value);
|
||||
}
|
||||
if (
|
||||
hassOptions.allowDataUrl &&
|
||||
tag === "a" &&
|
||||
|
||||
@@ -9735,6 +9735,7 @@
|
||||
},
|
||||
"caption": "LoRaWAN",
|
||||
"description": "Devices across your LoRaWAN connections",
|
||||
"icon": "[%key:ui::panel::config::devices::data_table::icon%]",
|
||||
"device": "Device",
|
||||
"connection": "Connection",
|
||||
"dev_eui": "DevEUI",
|
||||
@@ -10210,7 +10211,12 @@
|
||||
},
|
||||
"show_icon_and_title": "Show icon and title",
|
||||
"subview": "Subview",
|
||||
"max_columns": "Max number of sections wide",
|
||||
"max_columns": "Max sections side by side",
|
||||
"max_columns_helper": "Smaller screens fit fewer side by side, and phones stack them one under the other.",
|
||||
"max_columns_option": "{count} {count, plural,\n one {section}\n other {sections}\n}",
|
||||
"max_columns_option_default": "{count} sections (default)",
|
||||
"max_columns_custom_option": "Custom",
|
||||
"max_columns_custom": "Number of sections",
|
||||
"section_specifics": "Sections view specific settings",
|
||||
"dense_section_placement": "Dense section placement",
|
||||
"dense_section_placement_helper": "Will try to fill gaps with sections that fit the gap. This may make section placement less predictable.",
|
||||
|
||||
@@ -47,4 +47,39 @@ describe("rewriteHtmlUrls", () => {
|
||||
'<a href="https://example.com/repo/docs/setup.md"><img src="https://example.com/repo/badge.svg"></a>'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "a source of a video",
|
||||
input: '<video controls><source src="clip.mp4" type="video/mp4"></video>',
|
||||
output:
|
||||
'<video controls=""><source src="https://example.com/repo/clip.mp4" type="video/mp4"></video>',
|
||||
},
|
||||
{
|
||||
name: "a source of an audio",
|
||||
input: '<audio controls><source src="./sound.ogg"></audio>',
|
||||
output:
|
||||
'<audio controls=""><source src="https://example.com/repo/./sound.ogg"></audio>',
|
||||
},
|
||||
{
|
||||
name: "the srcset of a picture source",
|
||||
input: '<picture><source srcset="dark.png"></picture>',
|
||||
output:
|
||||
'<picture><source srcset="https://example.com/repo/dark.png"></picture>',
|
||||
},
|
||||
{
|
||||
name: "every address in a srcset and keeps the descriptors",
|
||||
input:
|
||||
'<source srcset="small.png 480w,large.png 1080w ,, huge.png 2x" sizes="50vw">',
|
||||
output:
|
||||
'<source srcset="https://example.com/repo/small.png 480w, https://example.com/repo/large.png 1080w, https://example.com/repo/huge.png 2x" sizes="50vw">',
|
||||
},
|
||||
{
|
||||
name: "a srcset with an entity in it, as the browser reads it",
|
||||
input: '<source srcset="img/a.png 1x">',
|
||||
output: '<source srcset="https://example.com/repo/img/a.png 1x">',
|
||||
},
|
||||
])("rewrites $name", ({ input, output }) => {
|
||||
expect(rewriteHtmlUrls(input, toRepository)).toBe(output);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expose } from "comlink";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { rewriteHtmlUrls } from "../../../src/common/dom/rewrite-html-urls";
|
||||
import type { RepositoryInfo } from "../../../src/data/marketplace/repository";
|
||||
import {
|
||||
markdownWithRepositoryContext,
|
||||
repositoryUrl,
|
||||
} from "../../../src/panels/marketplace/tools/markdown";
|
||||
import type { Api } from "../../../src/resources/markdown-worker";
|
||||
|
||||
vi.mock("comlink", () => ({ expose: vi.fn() }));
|
||||
|
||||
const repository = {
|
||||
id: "42",
|
||||
@@ -317,3 +322,82 @@ describe("markdownWithRepositoryContext", () => {
|
||||
expect(performance.now() - start).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteHtmlUrls with the README of a repository", () => {
|
||||
const toReadme = repositoryUrl(repository);
|
||||
const raw = "https://raw.githubusercontent.com/owner/repo/v1.0.0";
|
||||
|
||||
const srcsetOf = (input: string) =>
|
||||
rewriteHtmlUrls(`<source srcset="${input}">`, toReadme).match(
|
||||
/srcset="([^"]*)"/
|
||||
)?.[1];
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "relative addresses into the repository",
|
||||
input: "./images/logo.png 1x, images/[email protected] 2x",
|
||||
output: `${raw}/images/logo.png 1x, ${raw}/images/[email protected] 2x`,
|
||||
},
|
||||
{
|
||||
name: "a file shown on GitHub to raw",
|
||||
input:
|
||||
"https://github.com/owner/repo/blob/main/logo.png, https://github.com/other/repo/blob/v2/[email protected] 2x",
|
||||
output:
|
||||
"https://raw.githubusercontent.com/owner/repo/main/logo.png, https://raw.githubusercontent.com/other/repo/v2/[email protected] 2x",
|
||||
},
|
||||
{
|
||||
// Never requested from Home Assistant, same as the src of an image
|
||||
name: "an address at the root of the host into the repository",
|
||||
input: "/api/states 1x, /local/x.png 2x",
|
||||
output: `${raw}/api/states 1x, ${raw}/local/x.png 2x`,
|
||||
},
|
||||
{
|
||||
name: "relative and absolute addresses together",
|
||||
input: "a.png 100w, https://example.com/b.png 200w, /c.png 300w",
|
||||
output: `${raw}/a.png 100w, https://example.com/b.png 200w, ${raw}/c.png 300w`,
|
||||
},
|
||||
])("points $name", ({ input, output }) => {
|
||||
expect(srcsetOf(input)).toBe(output);
|
||||
expect(
|
||||
rewriteHtmlUrls(`<img src="${input.split(" ")[0]}">`, toReadme)
|
||||
).toBe(`<img src="${output.split(" ")[0]}">`);
|
||||
});
|
||||
|
||||
it("points the src of the source of a video", () => {
|
||||
expect(
|
||||
rewriteHtmlUrls(
|
||||
'<video><source src="/api/camera_proxy/camera.x"></video>',
|
||||
toReadme
|
||||
)
|
||||
).toBe(`<video><source src="${raw}/api/camera_proxy/camera.x"></video>`);
|
||||
});
|
||||
|
||||
it("shows the dark logo of a README from the repository", async () => {
|
||||
await import("../../../src/resources/markdown-worker");
|
||||
const { renderMarkdown } = vi.mocked(expose).mock.calls[0][0] as Api;
|
||||
const html = (
|
||||
await renderMarkdown(
|
||||
[
|
||||
"<picture>",
|
||||
' <source media="(prefers-color-scheme: dark)" srcset="./images/logo-dark.png">',
|
||||
' <img src="./images/logo-light.png" alt="Logo">',
|
||||
"</picture>",
|
||||
].join("\n"),
|
||||
{}
|
||||
)
|
||||
).join("");
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = rewriteHtmlUrls(html, toReadme);
|
||||
const picture = template.content.querySelector("picture")!;
|
||||
expect(picture.querySelector("source")?.getAttribute("media")).toBe(
|
||||
"(prefers-color-scheme: dark)"
|
||||
);
|
||||
expect(picture.querySelector("source")?.getAttribute("srcset")).toBe(
|
||||
`${raw}/images/logo-dark.png`
|
||||
);
|
||||
expect(picture.querySelector("img")?.getAttribute("src")).toBe(
|
||||
`${raw}/images/logo-light.png`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { expose } from "comlink";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { Api } from "../../src/resources/markdown-worker";
|
||||
|
||||
vi.mock("comlink", () => ({ expose: vi.fn() }));
|
||||
|
||||
let renderMarkdown: Api["renderMarkdown"];
|
||||
|
||||
const render = async (content: string, allowSvg = false) =>
|
||||
(await renderMarkdown(content, {}, { allowSvg })).join("");
|
||||
|
||||
// Parse HTML like the browser does, so tests see decoded attribute values
|
||||
const parse = (html: string) => {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html;
|
||||
return template.content;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await import("../../src/resources/markdown-worker");
|
||||
renderMarkdown = vi.mocked(expose).mock.calls[0][0].renderMarkdown;
|
||||
});
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
describe("picture", () => {
|
||||
it("renders picture and source elements", async () => {
|
||||
const html = await render(
|
||||
[
|
||||
"<picture>",
|
||||
' <source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.jpg">',
|
||||
' <img src="https://example.com/light.jpg" alt="Chart" width="800">',
|
||||
"</picture>",
|
||||
].join("\n")
|
||||
);
|
||||
const source = parse(html).querySelector("picture > source");
|
||||
expect(source?.getAttribute("media")).toBe(
|
||||
"(prefers-color-scheme: dark)"
|
||||
);
|
||||
expect(source?.getAttribute("srcset")).toBe(
|
||||
"https://example.com/dark.jpg"
|
||||
);
|
||||
expect(
|
||||
parse(html).querySelector("picture > img")?.getAttribute("src")
|
||||
).toBe("https://example.com/light.jpg");
|
||||
expect(html).not.toContain("<");
|
||||
});
|
||||
|
||||
it("keeps type and sizes on source", async () => {
|
||||
const source = parse(
|
||||
await render(
|
||||
'<picture><source type="image/webp" sizes="50vw" srcset="https://example.com/a.webp 1x, https://example.com/b.webp 2x"><img src="https://example.com/a.png"></picture>'
|
||||
)
|
||||
).querySelector("picture > source");
|
||||
expect(source?.getAttribute("type")).toBe("image/webp");
|
||||
expect(source?.getAttribute("sizes")).toBe("50vw");
|
||||
expect(source?.getAttribute("srcset")).toBe(
|
||||
"https://example.com/a.webp 1x, https://example.com/b.webp 2x"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not allow other attributes on source", async () => {
|
||||
const source = parse(
|
||||
await render(
|
||||
'<picture><source srcset="https://example.com/a.jpg" onerror="alert(1)"></picture>'
|
||||
)
|
||||
).querySelector("picture > source");
|
||||
expect(source?.getAttributeNames()).toEqual(["srcset"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([false, true])("srcset (allowSvg: %s)", (allowSvg) => {
|
||||
// The <source> the browser gets for a srcset
|
||||
const renderSource = async (srcset: string) => {
|
||||
const source = parse(
|
||||
await render(
|
||||
`<picture><source srcset="${srcset}"><img src="https://example.com/fallback.png"></picture>`,
|
||||
allowSvg
|
||||
)
|
||||
).querySelector("picture > source");
|
||||
// Don't let a test pass because the whole element was escaped
|
||||
expect(source).not.toBeNull();
|
||||
return source!;
|
||||
};
|
||||
|
||||
it.each([
|
||||
"https://example.com/dark.png",
|
||||
"https://example.com/a.jpg 1x, https://example.com/b.jpg 2x",
|
||||
"https://example.com/a.jpg 1x,https://example.com/b.jpg 2x",
|
||||
"https://example.com/a.jpg 400w,https://example.com/b.jpg 800w",
|
||||
"https://example.com/a.jpg 1x,, https://example.com/b.jpg 2x",
|
||||
"https://example.com/a.jpg 1.5x",
|
||||
"/local/a.jpg 1x,./b.jpg 2x",
|
||||
"//example.com/a.jpg 1x,../b.jpg 2x",
|
||||
"https://example.com/a.jpg?x=1&y=2 1x, https://example.com/b.jpg?x=1&y=2 2x",
|
||||
"https://example.com/a.jpg 1x, https://example.com/b.jpg 2x",
|
||||
"https://example.com/javascript:logo.svg 1x",
|
||||
])("keeps %s", async (srcset) => {
|
||||
// What the browser reads from the markup as written
|
||||
const expected = parse(`<source srcset="${srcset}">`)
|
||||
.querySelector("source")!
|
||||
.getAttribute("srcset");
|
||||
expect((await renderSource(srcset)).getAttribute("srcset")).toBe(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// eslint-disable-next-line no-script-url
|
||||
"javascript:alert(1)",
|
||||
" JaVaScRiPt:alert(1)",
|
||||
"javascript:alert(1)",
|
||||
"vbscript:msgbox(1)",
|
||||
"https://example.com/a.jpg 1x, javascript:alert(1) 2x",
|
||||
"https://example.com/a.jpg 1x,javascript:alert(1) 2x",
|
||||
"https://example.com/a.jpg 1x, javascript:alert(1) 2x",
|
||||
"https://example.com/a.jpg 1x,javascript:alert(1) 2x",
|
||||
"https://example.com/a.jpg 1x,blob:https://example.com/id 2x",
|
||||
// xss decodes these once and the browser would decode them again
|
||||
"https://example.com/a.jpg 1x&#44;java&#115;cript:alert(1) 2x",
|
||||
"https://example.com/a.jpg 1x,java&#115;cript:alert(1) 2x",
|
||||
"https://example.com/a.jpg 1x,data:text/html,x 2x",
|
||||
"data:&#105;mage/png 1x",
|
||||
// A comma inside parentheses does not end a descriptor
|
||||
"https://example.com/a.jpg 1x(,https://example.com/b)x,data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg== 2x",
|
||||
])("drops srcset with a disallowed URL: %s", async (srcset) => {
|
||||
expect((await renderSource(srcset)).hasAttribute("srcset")).toBe(false);
|
||||
});
|
||||
|
||||
// Splitting at every comma and space is stricter than the browser, so
|
||||
// these valid but rare forms are dropped and the <img> fallback shows.
|
||||
it.each([
|
||||
// A comma inside a URL
|
||||
"https://res.cloudinary.com/demo/image/upload/w_400,c_fill/a.jpg 1x",
|
||||
"data:image/png;base64,aGVsbG8= 1x",
|
||||
// A comma inside a parenthesized descriptor
|
||||
"https://example.com/a.jpg (ignored, x),https://example.com/b.jpg 2x",
|
||||
// Named references other than `&` are not decoded
|
||||
"https://example.com/a.jpg 1x,https://example.com/b.jpg 2x",
|
||||
])("drops srcset the strict check can't split: %s", async (srcset) => {
|
||||
expect((await renderSource(srcset)).hasAttribute("srcset")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an undecoded named reference as text", async () => {
|
||||
// If `,` became a comma here, the browser would see two URLs
|
||||
expect(
|
||||
(
|
||||
await renderSource(
|
||||
"https://example.com/a.jpg,javascript:alert(1) 1x"
|
||||
)
|
||||
).getAttribute("srcset")
|
||||
).toBe("https://example.com/a.jpg,javascript:alert(1) 1x");
|
||||
});
|
||||
|
||||
it("keeps quotes and angle brackets inside the attribute", async () => {
|
||||
const content = parse(
|
||||
await render(
|
||||
'<picture><source srcset="https://example.com/a.jpg?q="><b> 1x"></picture>',
|
||||
allowSvg
|
||||
)
|
||||
);
|
||||
const source = content.querySelector("picture > source");
|
||||
expect(source?.getAttributeNames()).toEqual(["srcset"]);
|
||||
expect(source?.getAttribute("srcset")).toBe(
|
||||
'https://example.com/a.jpg?q="><b> 1x'
|
||||
);
|
||||
expect(content.querySelector("b")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let an encoded quote add an attribute", async () => {
|
||||
const source = await renderSource(
|
||||
"https://example.com/a.jpg" onerror="alert(1)"
|
||||
);
|
||||
expect(source.hasAttribute("onerror")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["audio", "video"])("source src in %s", (tag) => {
|
||||
const renderSrc = async (src: string) =>
|
||||
parse(await render(`<${tag} controls><source src="${src}"></${tag}>`))
|
||||
.querySelector(`${tag} > source`)
|
||||
?.getAttribute("src");
|
||||
|
||||
it("keeps an https src", async () => {
|
||||
expect(await renderSrc("https://example.com/a.mp4")).toBe(
|
||||
"https://example.com/a.mp4"
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// eslint-disable-next-line no-script-url
|
||||
"javascript:alert(1)",
|
||||
"vbscript:msgbox(1)",
|
||||
"data:text/html,x",
|
||||
"blob:https://example.com/id",
|
||||
])("empties a disallowed src: %s", async (src) => {
|
||||
expect(await renderSrc(src)).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user