18F/web-design-standards

View on GitHub
packages/usa-time-picker/src/index.js

Summary

Maintainability
A
3 hrs
Test Coverage
const behavior = require("../../uswds-core/src/js/utils/behavior");
const selectOrMatches = require("../../uswds-core/src/js/utils/select-or-matches");
const { prefix: PREFIX } = require("../../uswds-core/src/js/config");
const {
  COMBO_BOX_CLASS,
  enhanceComboBox,
} = require("../../usa-combo-box/src/index");

const TIME_PICKER_CLASS = `${PREFIX}-time-picker`;
const TIME_PICKER = `.${TIME_PICKER_CLASS}`;
const MAX_TIME = 60 * 24 - 1;
const MIN_TIME = 0;
const DEFAULT_STEP = 30;
const MIN_STEP = 1;

const FILTER_DATASET = {
  filter:
    "0?{{ hourQueryFilter }}:{{minuteQueryFilter}}.*{{ apQueryFilter }}m?",
  apQueryFilter: "([ap])",
  hourQueryFilter: "([1-9][0-2]?)",
  minuteQueryFilter: "[\\d]+:([0-9]{0,2})",
};

/**
 * Parse a string of hh:mm into minutes
 *
 * @param {string} timeStr the time string to parse
 * @returns {number} the number of minutes
 */
const parseTimeString = (timeStr) => {
  let minutes;

  if (timeStr) {
    const [hours, mins] = timeStr.split(":").map((str) => {
      let value;
      const parsed = parseInt(str, 10);
      if (!Number.isNaN(parsed)) value = parsed;
      return value;
    });

    if (hours != null && mins != null) {
      minutes = hours * 60 + mins;
    }
  }

  return minutes;
};

/**
 * Enhance an input with the date picker elements
 *
 * @param {HTMLElement} el The initial wrapping element of the date picker component
 */
const transformTimePicker = (el) => {
  const timePickerEl = el.closest(TIME_PICKER);

  const initialInputEl = timePickerEl.querySelector(`input`);

  if (!initialInputEl) {
    throw new Error(`${TIME_PICKER} is missing inner input`);
  }

  const selectEl = document.createElement("select");

  [
    "id",
    "name",
    "required",
    "aria-label",
    "aria-labelledby",
    "disabled",
    "aria-disabled",
  ].forEach((name) => {
    if (initialInputEl.hasAttribute(name)) {
      const value = initialInputEl.getAttribute(name);
      selectEl.setAttribute(name, value);
      initialInputEl.removeAttribute(name);
    }
  });

  const padZeros = (value, length) => `0000${value}`.slice(-length);

  const getTimeContext = (minutes) => {
    const minute = minutes % 60;
    const hour24 = Math.floor(minutes / 60);
    const hour12 = hour24 % 12 || 12;
    const ampm = hour24 < 12 ? "am" : "pm";

    return {
      minute,
      hour24,
      hour12,
      ampm,
    };
  };

  const minTime = Math.max(
    MIN_TIME,
    parseTimeString(timePickerEl.dataset.minTime) || MIN_TIME
  );
  const maxTime = Math.min(
    MAX_TIME,
    parseTimeString(timePickerEl.dataset.maxTime) || MAX_TIME
  );
  const step = Math.floor(
    Math.max(MIN_STEP, timePickerEl.dataset.step || DEFAULT_STEP)
  );

  let defaultValue;
  for (let time = minTime; time <= maxTime; time += step) {
    const { minute, hour24, hour12, ampm } = getTimeContext(time);

    const option = document.createElement("option");
    option.value = `${padZeros(hour24, 2)}:${padZeros(minute, 2)}`;
    option.text = `${hour12}:${padZeros(minute, 2)}${ampm}`;
    if (option.text === initialInputEl.value) {
      defaultValue = option.value;
    }
    selectEl.appendChild(option);
  }

  timePickerEl.classList.add(COMBO_BOX_CLASS);

  // combo box properties
  Object.keys(FILTER_DATASET).forEach((key) => {
    timePickerEl.dataset[key] = FILTER_DATASET[key];
  });
  timePickerEl.dataset.disableFiltering = "true";
  timePickerEl.dataset.defaultValue = defaultValue;

  timePickerEl.appendChild(selectEl);
  initialInputEl.remove();
};

const timePicker = behavior(
  {},
  {
    init(root) {
      selectOrMatches(TIME_PICKER, root).forEach((timePickerEl) => {
        transformTimePicker(timePickerEl);
        enhanceComboBox(timePickerEl);
      });
    },
    FILTER_DATASET,
  }
);

module.exports = timePicker;