How to customise `<input>` element with tighter restrictions

A slightly different approach. It allows digits, only 1 period, and backspace. All the rest of KeyboardEvent.keys including ctrl + v and ctrl + c are ignored. But if wish to allow them, you can do so.

To check if the character is one of the 10 digits, I am using event.key since they can have two different codes: Digits[0-9] and Numpad[0-9]. But for the period and backspace, I am using event.code since they have only one code.

const input = document.querySelector("#number_input");

const App = {
  isDigit: function(key) {
    const digits = [
      "0",
      "1",
      "2",
      "3",
      "4",
      "5",
      "6",
      "7",
      "8",
      "9"
    ];
    return digits.includes(key);
  },
  isPeriod: function(code) {
    return code === "Period";
  },
  isBackSpace: function(code) {
    return code === "Backspace";
  },
  handleEvent: function(event) {
    const key = event.key;
    const code = event.code;
    const value = input.value;
    if (App.isDigit(key) || App.isPeriod(code) || App.isBackSpace(code)) {
      if (App.isPeriod(code) && value.indexOf(key) !== -1) {
        event.preventDefault();
      }
    } else {
      event.preventDefault();
    }
  }
};

input.onkeydown = App.handleEvent
<input id="number_input" />

A clever hack

Since you insist to use a number input. First use, a dummy text input which you can hide it using either CSS or Js and validate its value instead of the number input.

const input = document.querySelector("#number_input");
const dummyInput = document.querySelector("#dummy_input")
const App = {
  isDigit: function(key) {
    const digits = [
      "0",
      "1",
      "2",
      "3",
      "4",
      "5",
      "6",
      "7",
      "8",
      "9"
    ];
    return digits.includes(key);
  },
  isPeriod: function(code) {
    return code === "Period";
  },
  isBackSpace: function(code) {
    return code === "Backspace";
  },
  handleEvent: function(event) {
    const key = event.key;
    const code = event.code;
    const dummyValue = dummyInput.value;
    if (App.isBackSpace(code)) {
      dummyInput.value = dummyValue.substring(0, dummyValue.length - 1)
    } else {
      if (App.isDigit(key) || App.isPeriod(code)) {
        if (App.isPeriod(code) && dummyValue.indexOf(key) !== -1) {
          event.preventDefault();
        } else {
          dummyInput.value += event.key
        }
      } else {
        event.preventDefault();
      }
    }
  }
};

input.onkeydown = App.handleEvent
<input type="number" id="number_input" />
<input type="text" id="dummy_input" />

Update

All of the answers that use input[type="number"] have a problem. You can change the input's value to a negative number by mouse wheel/spinner. To fix the issue, set a minimum value for the input.

<input type="number" min="1" id="number_input" />

You need to listen for onchange events and then change value of the dummy input.

const input = document.querySelector("#number_input");
const dummyInput = document.querySelector("#dummy_input")
const App = {
  isDigit: function(key) {
    const digits = [
      "0",
      "1",
      "2",
      "3",
      "4",
      "5",
      "6",
      "7",
      "8",
      "9"
    ];
    return digits.includes(key);
  },
  isPeriod: function(code) {
    return code === "Period";
  },
  isBackSpace: function(code) {
    return code === "Backspace";
  },
  handleEvent: function(event) {
    const key = event.key;
    const code = event.code;
    const dummyValue = dummyInput.value;
    if (App.isBackSpace(code)) {
      dummyInput.value = dummyValue.substring(0, dummyValue.length - 1)
    } else {
      if (App.isDigit(key) || App.isPeriod(code)) {
        if (App.isPeriod(code) && dummyValue.indexOf(key) !== -1) {
          event.preventDefault();
        } else {
          dummyInput.value += event.key
        }
      } else {
        event.preventDefault();
      }
    }
  },
  handleChange: function(event) {
    dummyInput.value = event.target.value
  }
};

input.onkeydown = App.handleEvent;
input.onchange = App.handleChange;
<input type="number" min="1" id="number_input" />
<input type="text" id="dummy_input" />

Is there a way to signal that an <input> is of type number without using the type=number attribute setting. i.e. mobile devices recognise and display number pad etc

Use inputmode="decimal" instead of type="number" to signal a mobile device to use a number pad keyboard input. This way you can continue to use type="text" and process the input as needed.

See MDN for more info and inputtypes.com to test on a device.