diff --git a/addons/web/static/src/core/pager/pager.js b/addons/web/static/src/core/pager/pager.js
new file mode 100644
index 00000000000..cfdfb00ce13
--- /dev/null
+++ b/addons/web/static/src/core/pager/pager.js
@@ -0,0 +1,168 @@
+/** @odoo-module **/
+
+import { useAutofocus } from "../utils/hooks";
+
+const { Component } = owl;
+const { useExternalListener, useRef, useState } = owl.hooks;
+
+/**
+ * Pager
+ *
+ * The pager goes from 1 to total (included).
+ * The current value is minimum if limit === 1 or the interval:
+ * [minimum, minimum + limit[ if limit > 1].
+ * The value can be manually changed by clicking on the pager value and giving
+ * an input matching the pattern: min[,max] (in which the comma can be a dash
+ * or a semicolon).
+ * The pager also provides two buttons to quickly change the current page (next
+ * or previous).
+ * @extends Component
+ */
+export class Pager extends Component {
+ setup() {
+ this.state = useState({
+ isEditing: false,
+ isDisabled: false,
+ });
+
+ this.inputRef = useRef("input");
+ useExternalListener(document, "mousedown", this.onClickAway, { capture: true });
+
+ this.forceFocus = useAutofocus();
+ }
+
+ /**
+ * @returns {number}
+ */
+ get minimum() {
+ return this.props.offset + 1;
+ }
+ /**
+ * @returns {number}
+ */
+ get maximum() {
+ return Math.min(this.props.offset + this.props.limit, this.props.total);
+ }
+ /**
+ * @returns {string}
+ */
+ get value() {
+ const parts = [this.minimum];
+ if (this.props.limit > 1) {
+ parts.push(this.maximum);
+ }
+ return parts.join("-");
+ }
+ /**
+ * @returns {boolean} true iff there is only one page
+ */
+ get isSinglePage() {
+ return this.minimum === 1 && this.maximum === this.props.total;
+ }
+
+ /**
+ * @param {-1 | 1} direction
+ */
+ navigate(direction) {
+ let minimum = this.props.offset + this.props.limit * direction;
+ if (minimum >= this.props.total) {
+ minimum = 0;
+ } else if (minimum < 0 && this.props.limit === 1) {
+ minimum = this.props.total - 1;
+ } else if (minimum < 0 && this.props.limit > 1) {
+ minimum = this.props.total - (this.props.total % this.props.limit || this.props.limit);
+ }
+
+ this.update(minimum, this.props.limit);
+ }
+ /**
+ * @param {string} value
+ * @returns {{ minimum: number, maximum: number }}
+ */
+ parse(value) {
+ let [minimum, maximum] = value.trim().split(/\s*[\-\s,;]\s*/);
+ const clamp = (value) => Math.min(Math.max(value, 1), this.props.total);
+ return {
+ minimum: clamp(parseInt(minimum, 10)) - 1,
+ maximum: clamp(maximum ? parseInt(maximum, 10) : minimum),
+ };
+ }
+ /**
+ * @param {string} value
+ */
+ setValue(value) {
+ const { minimum, maximum } = this.parse(value);
+
+ if (!isNaN(minimum) && !isNaN(maximum) && minimum < maximum) {
+ this.update(minimum, maximum - minimum);
+ }
+ }
+ /**
+ * @param {number} offset
+ * @param {number} limit
+ */
+ async update(offset, limit) {
+ this.state.isDisabled = true;
+ await this.props.onUpdate({ offset, limit });
+ this.state.isDisabled = false;
+ this.state.isEditing = false;
+ }
+
+ /**
+ * @param {MouseEvent} ev
+ */
+ onClickAway(ev) {
+ if (ev.target !== this.inputRef.el) {
+ this.state.isEditing = false;
+ }
+ }
+ onInputBlur() {
+ this.state.isEditing = false;
+ }
+ /**
+ * @param {Event} ev
+ */
+ onInputChange(ev) {
+ this.setValue(ev.target.value);
+ if (!this.state.isDisabled) {
+ ev.preventDefault();
+ }
+ }
+ /**
+ * @param {KeyboardEvent} ev
+ */
+ onInputKeydown(ev) {
+ switch (ev.key) {
+ case "Enter":
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.setValue(ev.currentTarget.value);
+ break;
+ case "Escape":
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.state.isEditing = false;
+ break;
+ }
+ }
+ onValueClick() {
+ if (this.props.isEditable && !this.state.isEditing && !this.state.isDisabled) {
+ this.forceFocus();
+ this.state.isEditing = true;
+ }
+ }
+}
+Pager.template = "web.Pager";
+
+Pager.defaultProps = {
+ isEditable: true,
+ withAccessKey: true,
+};
+Pager.props = {
+ offset: Number,
+ limit: Number,
+ total: Number,
+ onUpdate: Function,
+ isEditable: Boolean,
+ withAccessKey: Boolean,
+};
diff --git a/addons/web/static/src/core/pager/pager.scss b/addons/web/static/src/core/pager/pager.scss
new file mode 100644
index 00000000000..962e6a2bbc6
--- /dev/null
+++ b/addons/web/static/src/core/pager/pager.scss
@@ -0,0 +1,24 @@
+.o_pager {
+ display: flex;
+ align-items: center;
+
+ .o_pager_counter {
+ min-width: $o-statbutton-height;
+ }
+
+ .o_pager_value {
+ display: inline-block;
+ }
+
+ span.o_pager_value {
+ border-bottom: 1px solid transparent;
+ }
+
+ input.o_pager_value {
+ text-align: right;
+ width: 60px;
+ &:focus {
+ outline: none;
+ }
+ }
+}
diff --git a/addons/web/static/src/core/pager/pager.xml b/addons/web/static/src/core/pager/pager.xml
new file mode 100644
index 00000000000..b8233fe2386
--- /dev/null
+++ b/addons/web/static/src/core/pager/pager.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/legacy/js/components/pager.js b/addons/web/static/src/legacy/js/components/pager.js
index 2d8d402afb1..92f6e3e927d 100644
--- a/addons/web/static/src/legacy/js/components/pager.js
+++ b/addons/web/static/src/legacy/js/components/pager.js
@@ -219,7 +219,7 @@ odoo.define('web.Pager', function (require) {
validate: Function,
withAccessKey: Boolean,
};
- Pager.template = 'web.Pager';
+ Pager.template = 'web.legacy.Pager';
return Pager;
});
diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml
index 54c5353210b..5bf1da2dcbb 100644
--- a/addons/web/static/src/legacy/xml/base.xml
+++ b/addons/web/static/src/legacy/xml/base.xml
@@ -292,7 +292,7 @@
-
+
-
+