[IMP] web: ListRenderer improve design, simplify scss

Part of the overall v16 SCSS optimization/restyle, task-2704984.

task-2946835

Part-of: odoo/odoo#98234
This commit is contained in:
stefanorigano (SRI)
2022-09-12 10:06:56 +02:00
committed by Pierre Paridans
parent eb1fa44652
commit 8add0bf5ec
7 changed files with 198 additions and 128 deletions
@@ -132,7 +132,7 @@ $headings-color: $o-main-headings-color !default;
$table-striped-color: inherit !default;
$table-striped-bg-factor: .01 !default;
$table-hover-bg: rgba($black, .04) !default;
$table-hover-bg-factor: .075 !default;
$table-border-color: $gray-200 !default;
$table-group-separator-color: $gray-200 !default;
$table-cell-padding-x: .75rem !default;
@@ -140,6 +140,10 @@ $table-cell-padding-y: .75rem !default;
$table-cell-padding-x-sm: .3rem !default;
$table-cell-padding-y-sm: .3rem !default;
$table-active-bg-factor: .05 !default;
$table-active-color: $headings-color !default;
$table-th-font-weight: $headings-font-weight !default;
// Buttons
//
$btn-border-radius-lg: $border-radius !default;
@@ -30,6 +30,7 @@
.o_input {
border:$input-border-width solid var(--o-input-border-color);
padding: $o-input-padding-y $o-input-padding-x;
background-color: var(--o-input-background-color, unset);
// -- Nested o_input(s)
.o_input {
@@ -1,32 +1,52 @@
.o_list_renderer {
position: relative;
height: 100%;
--ListRenderer-table-padding-h: #{$table-cell-padding-x-sm};
--ListRenderer-thead-padding-v: #{$table-cell-padding-y * 1.25};
--ListRenderer-thead-bg-color: #{$gray-200};
--ListRenderer-thead-border-end-color: #{$border-color};
.o_list_view & {
--ListRenderer-table-padding-h: #{$o-horizontal-padding};
}
.o_list_table {
position: relative;
cursor: pointer;
background-color: $o-view-background-color;
margin-bottom: 0px;
// Don't understand why but without those, the list view appears broken
// in both form views and standalone list views.
border-collapse: initial;
border-spacing: 0;
> thead, > tbody, > tfoot {
> tr > :first-child {
padding-left: var(--ListRenderer-table-padding-h);
}
thead {
color: $o-main-headings-color;
border-bottom: 1px solid #cacaca;
> tr > th:not(.o_list_record_selector) {
border-left: 1px solid #dfdfdf;
@include o-text-overflow(table-cell);
&:not(.o_column_sortable):hover {
cursor: default;
}
&.o_list_number_th {
text-align: right;
}
> tr > :last-child {
padding-right: var(--ListRenderer-table-padding-h);
}
> tr > :empty {
padding: 0;
}
}
thead, tfoot {
tr > :not(:empty) {
padding-top: var(--ListRenderer-thead-padding-v);
padding-bottom: var(--ListRenderer-thead-padding-v);
}
}
thead {
background-color: var(--ListRenderer-thead-bg-color);
color: $headings-color;
th:not(.o_list_record_selector) {
border-left: $border-width solid var(--ListRenderer-thead-border-end-color);
}
}
// Override BS default
> :not(:first-child) {
border-top-width: 0;
}
tbody > tr > td:not(.o_list_record_selector) {
&:not(.o_handle_cell):not(.o_list_button):not(.o_state_selection_cell) {
@include o-text-overflow(table-cell);
@@ -68,72 +88,32 @@
}
tfoot {
cursor: default;
// cursor: default;
color: $o-list-footer-color;
background-color: $o-list-footer-bg-color;
font-weight: $o-list-footer-font-weight;
border-top: 2px solid #cacaca;
border-bottom: 1px solid #cacaca;
border-top: 1px solid $table-border-color;
border-bottom: 1px solid $table-border-color;
> tr > td {
@include o-text-overflow(table-cell);
}
.o_list_number {
text-align: right;
}
}
.o_column_sortable:not(.o_handle_cell) {
position: relative;
user-select: none; // Prevent unwanted selection while sorting
&::after {
font-family: FontAwesome;
content: "\f0d7";
opacity: 0;
}
&:not(:empty)::after {
margin-left: 6px;
}
&.o-sort-up {
cursor: n-resize;
&::after {
content: "\f0d7";
}
}
&.o-sort-down {
cursor: s-resize;
&::after {
content: "\f0d8";
}
}
&:hover::after {
opacity: 0.3;
}
&.o-sort-up, &.o-sort-down {
color: $headings-color;
&::after {
opacity: 1;
}
}
}
.o_list_record_selector {
width: 40px;
padding-left: $o-horizontal-padding;
.o-checkbox {
margin-bottom: $form-check-margin-bottom;
}
.form-check-label {
cursor: pointer;
}
}
.o_list_record_remove, .o_handle_cell {
width: 1px; // to prevent the column to expand
}
.o_list_record_selector, .o_handle_cell {
max-width: 35px !important;
.o_list_table_ungrouped {
.o_list_controller {
max-width: 35px !important;
}
}
.o_list_record_remove button {
@@ -144,6 +124,42 @@
cursor: pointer;
}
tbody tr {
outline-offset: -1px;
&:hover {
outline: 1px solid $gray-400;
}
&.table-info {
outline-color: $info;
}
&.o_selected_row {
th:focus-within, td:focus-within {
--table-accent-bg: none;
background-color: $o-view-background-color;
outline-offset: -1px;
outline: 1px solid $primary;
}
}
}
.o_keyboard_navigation {
tr:hover {
outline-color: transparent;
}
th:focus-within, td:focus-within {
--table-accent-bg: none;
background-color: $o-view-background-color;
outline-offset: -1px;
outline: 1px solid $primary;
}
}
// Grouped list views
tbody > tr.o_group_header {
> th, > td {
@@ -189,16 +205,7 @@
border-top: none; // Override bootstrap for grouped list views
}
&, &.table-striped {
tr:focus-within {
background-color: lighten($o-form-lightsecondary, 10%);
}
}
thead th:focus-within,
tbody.o_keyboard_navigation td:focus-within {
background-color: $o-form-lightsecondary;
outline: none;
}
.o_data_cell.o_text_overflow,
.o_data_cell.o_invisible_modifier {
@@ -220,13 +227,7 @@
}
span.o_resize {
bottom: 0;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
width: 10px;
z-index: 1; // must be over the sort caret (::after pseudo-element)
}
.o_list_actions_header {
@@ -274,10 +275,7 @@
}
.o_data_row.o_selected_row > .o_data_cell {
position: relative; // for o_field_translate
&:not(.o_handle_cell) {
background-color: white;
}
> .o_field_widget:not(.o_readonly_modifier):not(.o_invisible_modifier) {
width: 100%;
.o_input {
@@ -306,6 +304,9 @@
// reduce basis to prevent many2one input from going to the next line to often
flex-basis: 40px;
}
.o_field_many2one_avatar {
display: flex;
}
&.o_field_badge {
width: auto; // override width: 100% to keep the optimal badge width
}
@@ -349,8 +350,3 @@
}
}
}
.o_list_view.o_view_controller > .o_content > .table-responsive {
// in main list views, let .o_content scroll
overflow: visible;
}
@@ -562,13 +562,15 @@ export class ListRenderer extends Component {
const field = this.fields[column.name];
const classNames = ["align-middle"];
if (field.sortable && column.hasLabel) {
classNames.push("o_column_sortable");
classNames.push("o_column_sortable", "position-relative", "cursor-pointer");
} else {
classNames.push("cursor-default");
}
const orderBy = this.props.list.orderBy;
if (orderBy.length && orderBy[0].name === column.name) {
classNames.push(orderBy[0].asc ? "o-sort-up" : "o-sort-down");
if (orderBy.length && column.widget !== "handle" && orderBy[0].name === column.name) {
classNames.push("table-active");
}
if (["float", "integer", "monetary"].includes(field.type)) {
if (this.isNumericColumn(column)) {
classNames.push("o_list_number_th");
}
if (column.type === "button_group") {
@@ -592,6 +594,24 @@ export class ListRenderer extends Component {
return this.state.columns;
}
isNumericColumn(column) {
const { type } = this.fields[column.name];
return ["float", "integer", "monetary"].includes(type);
}
getSortableIconClass(column) {
const { sortable } = this.fields[column.name];
const { orderBy } = this.props.list;
const classNames = sortable && column.hasLabel ? ["fa", "fa-lg", "px-2"] : ["d-none"];
if (orderBy.length && orderBy[0].name === column.name) {
classNames.push(orderBy[0].asc ? "fa-angle-up" : "fa-angle-down");
} else {
classNames.push("fa-angle-down", "opacity-0", "opacity-75-hover");
}
return classNames.join(" ");
}
/**
* Returns the classnames to apply to the row representing the given record.
* @param {Record} record
@@ -602,6 +622,9 @@ export class ListRenderer extends Component {
const classNames = this.props.archInfo.decorations
.filter((decoration) => evaluateExpr(decoration.condition, record.evalContext))
.map((decoration) => decoration.class);
if (record.selected) {
classNames.push("table-info");
}
// "o_selected_row" classname for the potential row in edition
if (record.isInEdition) {
classNames.push("o_selected_row");
@@ -661,6 +684,15 @@ export class ListRenderer extends Component {
}
}
}
if (
record.isInEdition &&
this.props.list.editedRecord &&
this.props.list.editedRecord.isReadonly(column.name)
) {
classNames.push("pe-none", "text-muted");
} else {
classNames.push("cursor-pointer");
}
}
return classNames.join(" ");
}
@@ -1592,6 +1624,24 @@ export class ListRenderer extends Component {
});
}
/**
* Handles the :hover effect on sortable column headers
*
* @private
* @param {MouseEvent} ev
*/
onHoverSortColumn(ev, column) {
if (this.props.list.orderBy.length && this.props.list.orderBy[0].name === column.name) {
return;
} else if (
this.fields[column.name].sortable &&
column.widget !== "handle" &&
column.hasLabel
) {
ev.target.classList.toggle("table-active", ev.type == "mouseenter");
}
}
/**
* Handles the resize feature on the column headers
*
@@ -1601,6 +1651,7 @@ export class ListRenderer extends Component {
onStartResize(ev) {
const table = this.tableRef.el;
const th = ev.target.closest("th");
const handler = th.querySelector(".o_resize");
table.style.width = `${Math.floor(table.getBoundingClientRect().width)}px`;
const thPosition = [...th.parentNode.children].indexOf(th);
const resizingColumnElements = [...table.getElementsByTagName("tr")]
@@ -1622,6 +1673,8 @@ export class ListRenderer extends Component {
table.classList.add("o_resizing");
for (const el of resizingColumnElements) {
el.classList.add("o_column_resizing");
handler.classList.add("bg-primary", "opacity-100");
handler.classList.remove("bg-black-25", "opacity-50-hover");
}
// Mousemove event : resize header
const resizeHeader = (ev) => {
@@ -1648,6 +1701,8 @@ export class ListRenderer extends Component {
table.classList.remove("o_resizing");
for (const el of resizingColumnElements) {
el.classList.remove("o_column_resizing");
handler.classList.remove("bg-primary", "opacity-100");
handler.classList.add("bg-black-25", "opacity-50-hover");
}
window.removeEventListener("mousemove", resizeHeader);
@@ -3,7 +3,7 @@
<t t-name="web.ListRenderer" owl="1">
<div
class="o_list_renderer o_renderer"
class="o_list_renderer o_renderer position-relative"
t-att-class="{'table-responsive': showTable}"
t-attf-class="{{ props.archInfo.className or '' }}"
tabindex="-1"
@@ -12,22 +12,38 @@
<t t-if="showNoContentHelper" t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.noContentHelp"/>
</t>
<table t-if="showTable" t-attf-class="o_list_table table table-sm table-hover table-striped {{props.list.isGrouped ? 'o_list_table_grouped' : 'o_list_table_ungrouped'}}" t-ref="table">
<table t-if="showTable" t-attf-class="o_list_table table table-sm position-relative mb-0 {{props.list.isGrouped ? 'o_list_table_grouped' : 'o_list_table_ungrouped table-striped'}}" t-ref="table">
<thead>
<tr>
<th t-if="hasSelectors" class="o_list_record_selector align-middle pe-1" t-att-class="{o_sample_data_disabled: props.list.model.useSampleModel}" tabindex="-1" t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" t-on-click.stop="toggleSelection">
<CheckBox disabled="!!props.list.editedRecord or props.list.model.useSampleModel" value="selectAll" onChange.bind="toggleSelection"/>
<th t-if="hasSelectors" class="o_list_record_selector o_list_controller align-middle pe-1 cursor-pointer" t-att-class="{o_sample_data_disabled: props.list.model.useSampleModel}" tabindex="-1" t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" t-on-click.stop="toggleSelection">
<CheckBox disabled="!!props.list.editedRecord or props.list.model.useSampleModel" value="selectAll" className="'d-flex'" onChange.bind="toggleSelection"/>
</th>
<t t-foreach="state.columns" t-as="column" t-key="column.id">
<th t-if="column.type === 'field'" t-att-data-name="column.name" t-att-class="getColumnClass(column)" t-on-click="() => this.onClickSortColumn(column)" tabindex="-1" t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" t-att-data-tooltip-template="isDebugMode ? 'web.FieldTooltip' : false" t-att-data-tooltip-info="isDebugMode ? makeTooltip(column) : false">
<th t-if="column.type === 'field'"
t-att-data-name="column.name"
t-att-class="getColumnClass(column) + ' opacity-trigger-hover'"
t-on-mouseenter="ev => this.onHoverSortColumn(ev, column)"
t-on-mouseleave="ev => this.onHoverSortColumn(ev, column)"
t-on-click="() => this.onClickSortColumn(column)"
t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" t-att-data-tooltip-template="isDebugMode ? 'web.FieldTooltip' : false" t-att-data-tooltip-info="isDebugMode ? makeTooltip(column) : false"
tabindex="-1">
<t t-if="column.hasLabel and column.widget !== 'handle'">
<t t-esc="column.label"/>
<span t-if="!isEmpty" class="o_resize" t-on-click.stop.prevent="" t-on-mousedown.stop.prevent="onStartResize"/>
<t t-set="isNumeric" t-value="isNumericColumn(column)"/>
<div t-att-class="{'d-flex': true, 'flex-row-reverse': isNumeric}">
<span class="d-block min-w-0 text-truncate flex-grow-1" t-att-class="isNumeric ? 'text-end' : ''"
t-esc="column.label"/>
<i t-if="!props.list.isGrouped"
t-att-class="getSortableIconClass(column)"/>
</div>
<span t-if="!isEmpty"
class="o_resize position-absolute top-0 end-0 bottom-0 ps-1 bg-black-25 opacity-0 opacity-50-hover z-index-1"
t-on-click.stop.prevent=""
t-on-mousedown.stop.prevent="onStartResize"/>
</t>
</th>
<th t-else="" t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" t-att-class="{o_list_button: column.type === 'button_group'}"/>
</t>
<th t-if="displayOptionalFields or (props.activeActions and props.activeActions.onDelete)" t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" class="o_list_actions_header" style="width: 32px; min-width: 32px">
<th t-if="displayOptionalFields or (props.activeActions and props.activeActions.onDelete)" t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" class="o_list_controller o_list_actions_header" style="width: 32px; min-width: 32px">
<Dropdown t-if="displayOptionalFields"
class="'o_optional_columns_dropdown text-center border-top-0'"
togglerClass="'btn p-0'"
@@ -56,12 +72,12 @@
<t t-set="list" t-value="props.list"/>
</t>
</tbody>
<tfoot t-on-click="() => props.list.unselectRecord(true)" t-att-class="{o_sample_data_disabled: props.list.model.useSampleModel}">
<tfoot t-on-click="() => props.list.unselectRecord(true)" class="cursor-default" t-att-class="{o_sample_data_disabled: props.list.model.useSampleModel}">
<tr>
<td t-if="hasSelectors"/>
<t t-foreach="state.columns" t-as="column" t-key="column.id">
<t t-set="aggregate" t-value="aggregates[column.name]"/>
<td t-if="aggregate" class="o_list_number" t-att-data-tooltip="aggregate.help">
<td t-if="aggregate" class="o_list_number text-end" t-att-data-tooltip="aggregate.help">
<t t-esc="aggregate.value"/>
</td>
<td t-else=""/>
@@ -142,11 +158,15 @@
</t>
<t t-name="web.ListRenderer.GroupRow" owl="1">
<tr t-attf-class="{{group.count > 0 ? 'o_group_has_content' : ''}} o_group_header {{!group.isFolded ? 'o_group_open' : ''}}"
<tr t-attf-class="{{group.count > 0 ? 'o_group_has_content' : ''}} o_group_header {{!group.isFolded ? 'o_group_open' : ''}} cursor-pointer"
t-on-click="() => this.toggleGroup(group)"
>
<th t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev, group)" tabindex="-1" class="o_group_name" t-att-colspan="getGroupNameCellColSpan(group)">
<span t-attf-class="fa {{group.isFolded ? 'fa-caret-right' : 'fa-caret-down' }}"
<th t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev, group)"
tabindex="-1"
t-attf-class="o_group_name fs-6 fw-bold {{!group.isFolded ? 'text-900' : 'text-700'}}"
t-att-colspan="getGroupNameCellColSpan(group)">
<span t-attf-class="fa fa-fw {{group.isFolded ? 'fa-caret-right' : 'fa-caret-down' }}"
t-attf-style="padding-left: {{2 + getGroupLevel(group) * 20}}px; padding-right: 5px"/>
<t t-esc="getGroupDisplayName(group)"/> (<t t-esc="group.count"/>)
<div t-if="(groupByButtons[group.groupByField.name] and !group.isFolded)" class="o_group_buttons">
@@ -197,7 +217,7 @@
<t t-foreach="getColumns(record)" t-as="column" t-key="column.id">
<t t-if="column.type === 'field'">
<td t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev, group, record)"
class="o_data_cell"
class="o_data_cell cursor-pointer"
t-att-class="getCellClass(column, record)"
t-att-name="column.name"
t-att-colspan="column.colspan"
@@ -233,7 +253,7 @@
<t t-if="displayOptionalFields or activeActions">
<t t-set="canDelete" t-value="activeActions.canDelete" />
<t t-set="canUnlink" t-value="activeActions.canUnlink" />
<td t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev, group, record)" t-if="'canUnlink' in activeActions ? canUnlink : canDelete" class="o_list_record_remove" t-on-click.stop="() => this.onDeleteRecord(record)" tabindex="-1">
<td t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev, group, record)" t-if="'canUnlink' in activeActions ? canUnlink : canDelete" class="o_list_record_remove text-center" t-on-click.stop="() => this.onDeleteRecord(record)" tabindex="-1">
<button class="fa"
t-att-class="{ 'fa-trash-o': 'canUnlink' in activeActions ? false : canDelete, 'fa-times': 'canUnlink' in activeActions ? canUnlink : false }"
name="delete"
@@ -1,9 +1,5 @@
.o_list_view {
thead {
background-color: #eee;
}
tbody > tr.o_group_header {
background-image: linear-gradient(to bottom, #fcfcfc, #dedede);
@@ -254,9 +254,9 @@ QUnit.module("Views", (hooks) => {
assert.containsN(target, "tbody tr", 4, "should have 4 rows");
assert.containsOnce(target, "th.o_column_sortable", "should have 1 sortable column");
assert.strictEqual(
$(target).find("thead th:nth(2)").css("text-align"),
"right",
assert.containsOnce(
target,
"thead th:nth(2) .text-end",
"header cells of integer fields should be right aligned"
);
assert.strictEqual(
@@ -3495,7 +3495,7 @@ QUnit.module("Views", (hooks) => {
const [titi, grosminet] = target.querySelectorAll(".tab-pane:last-child th");
assert.ok(
titi.style.width.split("px")[0] > 80 && grosminet.style.width.split("px")[0] > 700,
titi.style.width.split("px")[0] > 80 && grosminet.style.width.split("px")[0] > 500,
"list has been correctly frozen after being visible"
);
}
@@ -3649,9 +3649,8 @@ QUnit.module("Views", (hooks) => {
const thRect = charTh.getBoundingClientRect();
const resizeRect = charTh.querySelector(".o_resize").getBoundingClientRect();
assert.strictEqual(
thRect.x + thRect.width,
resizeRect.x + resizeRect.width,
assert.ok(
resizeRect.right - thRect.right <= 1,
"First resize handle should be attached at the end of the first header"
);
assert.containsNone(
@@ -3900,7 +3899,6 @@ QUnit.module("Views", (hooks) => {
</tree>`,
});
assert.strictEqual($(target).find('th[data-name="datetime"]')[0].offsetWidth, 146);
assert.strictEqual($(target).find('th[data-name="int_field"]')[0].offsetWidth, 200);
}
);
@@ -3992,7 +3990,7 @@ QUnit.module("Views", (hooks) => {
);
assert.strictEqual(
window.getComputedStyle(target.querySelectorAll("th")[2]).maxWidth,
"100%",
"none",
"no max-width should be harcoded on the buttons column"
);
});