Commit 0d21dd9d authored by Andrea Giammarchi's avatar Andrea Giammarchi Committed by Winsley

Issue 6875 - IOFilterTable Component

parent 64651b9f
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
@import "./io-checkbox.scss"; @import "./io-checkbox.scss";
@import "./io-toggle.scss"; @import "./io-toggle.scss";
@import "./io-scrollbar.scss";
/* /*
The component depends on its style and it will look for the The component depends on its style and it will look for the
...@@ -24,6 +25,7 @@ ...@@ -24,6 +25,7 @@
The property is also named like the component on purpose, The property is also named like the component on purpose,
to be sure its an own property, not something inherited. to be sure its an own property, not something inherited.
*/ */
io-filter-list io-filter-list
{ {
--io-filter-list: ready; --io-filter-list: ready;
...@@ -92,9 +94,16 @@ io-filter-list td ...@@ -92,9 +94,16 @@ io-filter-list td
io-filter-list th io-filter-list th
{ {
display: flex;
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease-in; transition: background 0.2s ease-in;
align-items: center;
}
io-filter-list th:not([data-column="rule"])
{
justify-content: center;
} }
io-filter-list th:hover io-filter-list th:hover
...@@ -169,18 +178,13 @@ io-filter-list td[data-column="warning"] img ...@@ -169,18 +178,13 @@ io-filter-list td[data-column="warning"] img
io-filter-list thead th:not([data-column="selected"])::after io-filter-list thead th:not([data-column="selected"])::after
{ {
display: inline-block; display: inline-block;
position: absolute; width: 24px;
padding: 4px; padding: 4px;
opacity: 0.3; opacity: 0.3;
font-size: 0.7em; font-size: 0.7em;
line-height: 1rem; line-height: 1rem;
} }
io-filter-list thead th[data-column="status"]::after
{
position: initial;
}
io-filter-list thead th:not([data-column="selected"])::after io-filter-list thead th:not([data-column="selected"])::after
{ {
content: "▲"; content: "▲";
......
/*
* This file is part of Adblock Plus <https://adblockplus.org/>,
* Copyright (C) 2006-present eyeo GmbH
*
* Adblock Plus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* Adblock Plus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
*/
@import "./io-filter-search.scss";
@import "./io-filter-list.scss";
io-filter-table
{
display: flex;
flex-direction: column;
min-width: 700px;
}
io-filter-table > io-filter-search
{
z-index: 1;
}
io-filter-table > io-filter-search > input
{
padding-right: 24px;
padding-left: 24px;
}
io-filter-table > io-filter-list > table
{
border-top: 0;
}
io-filter-table .footer
{
display: flex;
visibility: hidden;
flex-direction: row;
margin-top: 16px;
align-items: center;
}
io-filter-table .footer.visible
{
visibility: visible;
}
io-filter-table .footer button
{
display: inline-flex;
padding: 4px;
font-weight: 600;
justify-content: center;
align-content: center;
}
io-filter-table .footer button:not(:last-child)
{
text-transform: uppercase;
}
io-filter-table .footer button:not(:first-child)
{
margin: auto 16px;
}
io-filter-table .footer .delete
{
border: 2px solid #db394b;
color: #db394b;
}
io-filter-table .footer .copy
{
border: 2px solid #337ba2;
color: #337ba2;
}
io-filter-table .footer button:not(.error)::before
{
display: inline-block;
width: 1em;
height: 1em;
background-repeat: no-repeat;
background-size: contain;
content: "";
transform: translateY(0.1em);
}
io-filter-table .footer .delete::before
{
background-image: url(icons/trash.svg?error#error);
}
io-filter-table .footer .copy::before
{
background-image: url(icons/copy.svg);
}
io-filter-table .footer .error
{
border: 0;
color: #db394b;
text-transform: none;
flex-grow: 1;
}
io-filter-table .footer .error:not([data-filter])
{
outline: none;
cursor: default;
}
html:not([dir="rtl"]) io-filter-table .footer .error
{
text-align: left;
}
html:not([dir="rtl"]) io-filter-table .footer .delete::before,
html:not([dir="rtl"]) io-filter-table .footer .copy::before
{
margin-right: 4px;
}
html[dir="rtl"] io-filter-table .footer .error
{
text-align: right;
}
html[dir="rtl"] io-filter-table .footer .delete::before,
html[dir="rtl"] io-filter-table .footer .copy::before
{
margin-left: 4px;
}
...@@ -146,7 +146,7 @@ class IOFilterList extends IOElement ...@@ -146,7 +146,7 @@ class IOFilterList extends IOElement
const {position, range} = this.scrollbar; const {position, range} = this.scrollbar;
const {scrollHeight} = this.state; const {scrollHeight} = this.state;
this.setState({ this.setState({
scrollTop: scrollHeight * position / range scrollTop: getScrollTop(scrollHeight * position / range)
}); });
}); });
this.addEventListener( this.addEventListener(
...@@ -163,14 +163,10 @@ class IOFilterList extends IOElement ...@@ -163,14 +163,10 @@ class IOFilterList extends IOElement
} }
const {scrollHeight, scrollTop} = this.state; const {scrollHeight, scrollTop} = this.state;
this.setState({ this.setState({
scrollTop: Math.max( scrollTop: getScrollTop(scrollTop + event.deltaY, scrollHeight)
0,
Math.min(scrollHeight, scrollTop + event.deltaY)
)
}); });
// update the scrollbar position accordingly // update the scrollbar position accordingly
const {range} = this.scrollbar; updateScrollbarPosition.call(this);
this.scrollbar.position = this.state.scrollTop * range / scrollHeight;
}, },
{passive: false} {passive: false}
); );
...@@ -186,12 +182,12 @@ class IOFilterList extends IOElement ...@@ -186,12 +182,12 @@ class IOFilterList extends IOElement
if (index < 0) if (index < 0)
console.error("invalid filter", row); console.error("invalid filter", row);
else else
{
this.setState({ this.setState({
scrollTop: Math.min( scrollTop: getScrollTop(index * rowHeight, scrollHeight)
scrollHeight,
index * rowHeight
)
}); });
updateScrollbarPosition.call(this);
}
} }
onload() onload()
...@@ -436,9 +432,12 @@ class IOFilterList extends IOElement ...@@ -436,9 +432,12 @@ class IOFilterList extends IOElement
list[count++] = i < length ? this.state.filters[i++] : null; list[count++] = i < length ? this.state.filters[i++] : null;
} }
} }
const {length} = this.filters;
this.html`<table cellpadding="0" cellspacing="0"> this.html`<table cellpadding="0" cellspacing="0">
<thead onclick="${this}" data-call="onheaderclick"> <thead onclick="${this}" data-call="onheaderclick">
<th data-column="selected"><io-checkbox /></th> <th data-column="selected">
<io-checkbox checked=${!!length && this.selected.size === length} />
</th>
<th data-column="status"></th> <th data-column="status"></th>
<th data-column="rule">${{i18n: "options_filter_list_rule"}}</th> <th data-column="rule">${{i18n: "options_filter_list_rule"}}</th>
<th data-column="warning">${ <th data-column="warning">${
...@@ -575,7 +574,7 @@ function focusTheNextFilterIfAny(tr) ...@@ -575,7 +574,7 @@ function focusTheNextFilterIfAny(tr)
if (next.offsetTop > viewHeight) if (next.offsetTop > viewHeight)
{ {
this.setState({ this.setState({
scrollTop: scrollTop + rowHeight scrollTop: getScrollTop(scrollTop + rowHeight)
}); });
} }
// focus its content field // focus its content field
...@@ -599,6 +598,17 @@ function getFilter(event) ...@@ -599,6 +598,17 @@ function getFilter(event)
return div.data; return div.data;
} }
// ensure the number is always between 0 and a positive number
// specially handy when filters are erased and the viewHeight
// is higher than scrollHeight and other cases too
function getScrollTop(value, scrollHeight)
{
return Math.max(
0,
Math.min(scrollHeight || Infinity, value)
);
}
function getWarning(filter) function getWarning(filter)
{ {
if (filter.reason) if (filter.reason)
...@@ -671,3 +681,10 @@ function setupPort() ...@@ -671,3 +681,10 @@ function setupPort()
} }
}); });
} }
function updateScrollbarPosition()
{
const {scrollbar, state} = this;
const {scrollHeight, scrollTop} = state;
scrollbar.position = scrollTop * scrollbar.range / scrollHeight;
}
/*
* This file is part of Adblock Plus <https://adblockplus.org/>,
* Copyright (C) 2006-present eyeo GmbH
*
* Adblock Plus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* Adblock Plus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const IOElement = require("./io-element");
const IOFilterList = require("./io-filter-list");
const IOFilterSearch = require("./io-filter-search");
const {$, clipboard} = require("./dom");
const {bind, wire} = IOElement;
// io-filter-table is a basic controller
// used to relate the search and the list
class IOFilterTable extends IOElement
{
static get booleanAttributes()
{
return ["disabled"];
}
static get observedAttributes()
{
return ["match"];
}
get defaultState()
{
return {filters: [], match: -1, ready: false};
}
created()
{
this._showing = null;
this.search = this.appendChild(new IOFilterSearch());
this.search.addEventListener(
"filter:add",
event => this.onFilterAdd(event)
);
this.search.addEventListener(
"filter:match",
event => this.onFilterMatch(event)
);
this.list = this.appendChild(new IOFilterList());
this.footer = this.appendChild(wire()`<div class="footer" />`);
this.addEventListener("click", this);
this.addEventListener("error", this);
this.setState({ready: true});
}
attributeChangedCallback(name, prev, value)
{
if (name === "match")
this.setState({match: value}, false);
this.render();
}
get filters()
{
return this.state.filters;
}
set filters(value)
{
this.setState({filters: value});
}
get match()
{
return this.state.match;
}
set match(value)
{
this.setState({match: value});
}
onclick(event)
{
if (event.target.closest("io-checkbox"))
{
this.footer.classList.toggle("visible", !!this.list.selected.size);
}
}
onerror(event)
{
const {filter, errors} = event.detail;
const node = this.querySelector(".footer .error");
if (filter)
node.dataset.filter = filter;
else
delete node.dataset.filter;
bind(node)`${
errors ?
errors.join("\n") :
{i18n: "filter_action_failed"}
}}`;
}
onerrorclick(event)
{
const {filter} = event.currentTarget.dataset;
if (filter)
{
this.list.scrollTo(filter);
}
}
onfooterclick(event)
{
const {classList} = event.currentTarget;
switch (true)
{
case classList.contains("delete"):
const resolve = [];
for (const filter of this.list.selected)
{
this.list.selected.delete(filter);
this.filters.splice(this.filters.indexOf(filter), 1);
resolve.push(browser.runtime.sendMessage({
type: "filters.remove",
text: filter.text
}));
}
Promise.all(resolve).then(
() => updateList(this.list),
(errors) => this.onerror({detail: {errors}})
);
break;
case classList.contains("copy"):
const filters = [];
for (const filter of this.list.selected)
{
filters.push(filter.text);
}
clipboard.copy(filters.join("\n"));
break;
}
}
onFilterAdd(event)
{
const unknown = new WeakSet();
const filters = event.detail
.split(/(?:\r\n|\n)/)
.map(text =>
{
let value = this.filters.find(
filter => filter.text === text
);
if (!value)
{
value = {text};
unknown.add(value);
}
return value;
});
browser.runtime.sendMessage({
type: "filters.importRaw",
text: filters.map(filter => filter.text).join("\n")
})
.then(errors =>
{
if (!errors.length)
{
for (const filter of filters)
{
if (!unknown.has(filter))
this.filters.splice(this.filters.indexOf(filter), 1);
this.filters.unshift(filter);
}
updateList(this.list);
this.list.scrollTo(this.filters[0]);
this.search.value = "";
}
else
{
this.onerror({detail: {errors}});
}
});
}
onFilterMatch(event)
{
this.list.scrollTo(event.detail.filter);
}
render()
{
const {disabled} = this;
const {filters, match, ready} = this.state;
if (!ready)
return;
// simply update inner components
// no need to render any html in here
this.search.disabled = disabled;
this.search.filters = filters;
this.search.match = match;
this.list.disabled = disabled;
this.list.filters = filters;
this.renderFooter();
}
renderFooter()
{
bind(this.footer)`
<button
class="delete"
onclick="${this}"
data-call="onfooterclick"
>${{i18n: "delete"}}</button>
<button
class="copy"
onclick="${this}"
data-call="onfooterclick"
>${{i18n: "copy_selected"}}</button>
<button
class="error"
onclick="${this}"
data-call="onerrorclick"
></button>
`;
}
}
IOFilterTable.define("io-filter-table");
function updateList(list)
{
list.render();
list.updateScrollbar();
}
...@@ -7,6 +7,14 @@ ...@@ -7,6 +7,14 @@
"description": "Cancel button label", "description": "Cancel button label",
"message": "Cancel" "message": "Cancel"
}, },
"copy_selected": {
"description": "Copy selected items button label",
"message": "Copy selected"
},
"delete": {
"description": "Delete button label",
"message": "Delete"
},
"common_feature_anti_adblock_title": { "common_feature_anti_adblock_title": {
"description": "Feature title for anti-adblock blocking filter list", "description": "Feature title for anti-adblock blocking filter list",
"message": "Hide Adblock Warning Messages" "message": "Hide Adblock Warning Messages"
...@@ -26,5 +34,9 @@ ...@@ -26,5 +34,9 @@
"common_notification_hide": { "common_notification_hide": {
"description": "Hidden text attached to the close button for screen readers of Notification", "description": "Hidden text attached to the close button for screen readers of Notification",
"message": "Close notification" "message": "Close notification"
},
"filter_action_failed": {
"description": "One or more filters could not be saved or updated.",
"message": "Something went wrong. Please try again."
} }
} }
...@@ -95,6 +95,7 @@ ...@@ -95,6 +95,7 @@
}, },
"io-filter-search": "$ create.test.component io-filter-search", "io-filter-search": "$ create.test.component io-filter-search",
"io-filter-list": "$ create.test.component io-filter-list", "io-filter-list": "$ create.test.component io-filter-list",
"io-filter-table": "$ create.test.component io-filter-table",
"io-highlighter": { "io-highlighter": {
"css": [ "css": [
"$ create.test.css io-highlighter", "$ create.test.css io-highlighter",
......
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14px" height="17px" viewBox="0 0 14 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="old-Settings" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Old-Advanced-tab" transform="translate(-396.000000, -1323.000000)" fill="#077CA6" fill-rule="nonzero">
<g id="Copy-btn" transform="translate(380.000000, 1314.000000)">
<g id="copy-content" transform="translate(16.000000, 9.000000)">
<path d="M11.0035088,0 L1.57192982,0 C0.707368421,0 0,0.736363636 0,1.63636364 L0,13.0909091 L0.975671029,13.0909091 L0.975671029,1.01554355 L11.0035088,1.01554355 L11.0035088,0 Z M12.4280702,2.27272727 L3.78245614,2.27272727 C2.91789474,2.27272727 2.21052632,3.00909091 2.21052632,3.90909091 L2.21052632,15.3636364 C2.21052632,16.2636364 2.91789474,17 3.78245614,17 L12.4280702,17 C13.2926316,17 14,16.2636364 14,15.3636364 L14,3.90909091 C14,3.00909091 13.2926316,2.27272727 12.4280702,2.27272727 Z M12.148538,16.3181818 L4.0619883,16.3181818 C3.50970355,16.3181818 3.0619883,15.8704666 3.0619883,15.3181818 L3.0619883,3.95454545 C3.0619883,3.4022607 3.50970355,2.95454545 4.0619883,2.95454545 L12.148538,2.95454545 C12.7008228,2.95454545 13.148538,3.4022607 13.148538,3.95454545 L13.148538,15.3181818 C13.148538,15.8704666 12.7008228,16.3181818 12.148538,16.3181818 Z" id="Shape"></path>
</g>
</g>
</g>
</g>
</svg>
\ No newline at end of file
...@@ -12,4 +12,12 @@ ...@@ -12,4 +12,12 @@
<rect x="15.96" y="16" width="2" height="22" fill="#5CBCE1"/> <rect x="15.96" y="16" width="2" height="22" fill="#5CBCE1"/>
<path d="M44,7H33V5a5,5,0,0,0-5-5H20a5,5,0,0,0-5,5V7H4V9H6.68V39a9,9,0,0,0,9,9H32.23a9,9,0,0,0,9-9V9H44ZM17,5a3,3,0,0,1,3-3h8a3,3,0,0,1,3,3V7H17ZM39.23,39a7,7,0,0,1-7,7H15.68a7,7,0,0,1-7-7V9H39.23Z" fill="#5CBCE1"/> <path d="M44,7H33V5a5,5,0,0,0-5-5H20a5,5,0,0,0-5,5V7H4V9H6.68V39a9,9,0,0,0,9,9H32.23a9,9,0,0,0,9-9V9H44ZM17,5a3,3,0,0,1,3-3h8a3,3,0,0,1,3,3V7H17ZM39.23,39a7,7,0,0,1-7,7H15.68a7,7,0,0,1-7-7V9H39.23Z" fill="#5CBCE1"/>
</g> </g>
<view id="error" viewBox="0 96 48 48"/>
<g transform="translate(0, 96)">
<rect x="22.96" y="16" width="2" height="22" fill="#DB394B"/>
<rect x="29.96" y="16" width="2" height="22" fill="#DB394B"/>
<rect x="15.96" y="16" width="2" height="22" fill="#DB394B"/>
<path d="M44,7H33V5a5,5,0,0,0-5-5H20a5,5,0,0,0-5,5V7H4V9H6.68V39a9,9,0,0,0,9,9H32.23a9,9,0,0,0,9-9V9H44ZM17,5a3,3,0,0,1,3-3h8a3,3,0,0,1,3,3V7H17ZM39.23,39a7,7,0,0,1-7,7H15.68a7,7,0,0,1-7-7V9H39.23Z" fill="#DB394B"/>
</g>
</svg> </svg>
\ No newline at end of file
<!DOCTYPE html>
<!--
- This file is part of Adblock Plus <https://adblockplus.org/>,
- Copyright (C) 2006-present eyeo GmbH
-
- Adblock Plus is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License version 3 as
- published by the Free Software Foundation.
-
- Adblock Plus is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
-->
<html>
<head>
<meta charset="utf-8">
<title>Test io-filter-table.js</title>
<link rel="stylesheet" href="../skin/common.css">
<link rel="stylesheet" href="../skin/fonts.css">
<link rel="stylesheet" href="../skin/desktop-options.css">
<link rel="stylesheet" href="../smoke/css/io-filter-table.css">
<script defer src="../polyfill.js"></script>
<script defer src="../ext/common.js"></script>
<script defer src="../ext/content.js"></script>
<script defer src="../common.js"></script>
<script defer src="../i18n.js"></script>
<script defer src="./io-filter-table.js"></script>
</head>
<body>
<div style="width:500px;">
<io-filter-table></io-filter-table>
</div>
</body>
</html>
"use strict";
require("../js/io-filter-table");
const length = (Math.random() * 50) >>> 0;
const ioFilterTable = document.querySelector("io-filter-table");
ioFilterTable.filters = require("./random-filter-list")(length);
{} {
\ No newline at end of file "copy_selected": {
"description": "Copy selected items button label",
"message": "Copy selected"
},
"delete": {
"description": "Delete button label",
"message": "Delete"
},
"filter_action_failed": {
"description": "One or more filters could not be saved or updated.",
"message": "Something went wrong. Please try again."
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment