dan-lee / timescape

A flexible, headless date and time input library for JavaScript. Provides tools for building fully customizable date and time input fields, with support for libraries like React, Preact, Vue, Svelte and Solid.
https://timescape.daniellehr.de
MIT License
155 stars 2 forks source link
date headless input react solidjs svelte time vue

timescape

A powerful, headless library that elegantly fills the void left by HTML's native <input type="time"> and <input type="date">.

timescape is a toolkit for creating custom date and time input components. It helps you handle date and time data easily while giving you full control over the design and presentation. timescape supports multiple libraries, including React, Vue, Preact, Svelte, Solid, and native JavaScript.

Key features such as accessibility and keyboard navigation are at the core of timescape, allowing you to focus on creating user-centric date and time inputs that integrate seamlessly into your projects.

See Storybook or check out the examples of how to use it + StackBlitz ⚑ for more demonstrations.

Sponsored by Stellate

Features

Installation

# pnpm
pnpm add timescape

# yarn
yarn add timescape

# npm
npm install --save timescape

Examples

React [Edit on StackBlitz ⚑](https://stackblitz.com/edit/timescape-react?file=src%2FApp.tsx) ```tsx import { useTimescape } from "timescape/react"; function App() { const { getRootProps, getInputProps, options, update } = useTimescape({ date: new Date(), onChangeDate: (nextDate) => { console.log("Date changed to", nextDate); }, }); // To change any option: // update((prev) => ({ ...prev, date: new Date() })) return (
/ / : :
); } ```
Preact [Edit on StackBlitz ⚑](https://stackblitz.com/edit/timescape-preact?file=src%2Fapp.tsx) This package uses Preact signals, if you want to use it without just use the React implementation in compat mode. ```tsx import { effect } from "@preact/signals"; import { useTimescape } from "timescape/preact"; function App() { const { getRootProps, getInputProps, options } = useTimescape({ date: new Date(), }); effect(() => { console.log("Date changed to", options.value.date); }); // To change any option: // options.value = { ...options.value, date: new Date() } return (
/ /
); } ```
Vue [Edit on StackBlitz ⚑](https://stackblitz.com/edit/timescape-vue?file=src%2FApp.vue) ```vue ```
Svelte [Edit on StackBlitz ⚑](https://stackblitz.com/edit/timescape-svelte?file=src%2FApp.svelte) ```svelte
/ /
```
Solid [Edit on StackBlitz ⚑](https://stackblitz.com/edit/timescape-solid?file=src%2FApp.tsx) ```tsx import { createEffect } from "solid-js"; import { useTimescape } from "timescape/solid"; function App() { const { getInputProps, getRootProps, options, update } = useTimescape({ date: new Date(), }); createEffect(() => { console.log("Date changed to", options.date); }); // To change any option: // update('date', new Date()) // or update({ date: new Date() }) return (
/ /
); } ```
Vanilla JS ```tsx import { TimescapeManager } from "timescape"; const container = document.createElement("div"); document.body.appendChild(container); container.innerHTML = `
/ /
`; const timeManager = new TimescapeManager(); timeManager.date = new Date(); timeManager.subscribe((nextDate) => { console.log("Date changed to", nextDate); }); timeManager.registerRoot(document.getElementById("timescape-root")!); timeManager.registerElement( container.querySelector('[data-type="days"]')!, "days", ); timeManager.registerElement( container.querySelector('[data-type="months"]')!, "months", ); timeManager.registerElement( container.querySelector('[data-type="years"]')!, "years", ); ```

Options

The options passed to timescape are the initial values. timescape returns the options either as store/signal or with an updater function (depending on the library you are using).

type Options = {
  date?: Date;
  minDate?: Date | $NOW; // see more about $NOW below
  maxDate?: Date | $NOW;
  hour12?: boolean;
  wrapAround?: boolean;
  digits?: "numeric" | "2-digit";
  snapToStep?: boolean;
  wheelControl?: boolean;
  disallowPartial?: boolean
};
Option Default Description
date undefined The initial date. If not set, it will render the placeholders in their respective input fields (if set).
minDate undefined The minimum date that the user can select. $NOW is a special value that represents the current date and time. See more below
maxDate undefined The maximum date that the user can select. $NOW is a special value that represents the current date and time. See more below
hour12 false If set to true, the time input will use a 12-hour format (with AM/PM). If set to false, it will use a 24-hour format.
digits '2-digit' Controls the display of the day and month in the date input. 'numeric' displays as 1-12 for month and 1-31 for day, while '2-digit' displays as 01-12 for month and 01-31 for day. This follows Intl.DateTimeFormat convention.
wrapAround false If set to true, the time input will wrap around from the end of one period (AM/PM or day) to the beginning of the next.
snapToStep false If set to true, the input value will snap to the nearest step when the user uses arrow keys to increment/decrement values. Can be further adjust by using the step attribute
wheelControl false If set to true, the user can use the mouse wheel or touchpad to increment/decrement values.
disallowPartial false If true, the input requires fully completed dates and times. By default partial dates are allowed, similar to native HTML input behavior.

$NOW value

$NOW is a convenience value you can use for minDate and maxDate. It represents the current date and time at the moment of the user's interaction, dynamically adjusting to always reflect the current datetime value. This means you don't need to manually update it, as it always keeps itself current.

$NOW is exported as a constant for better type safety. By doing so, it eliminates the need for casting it as const, which would be required if $NOW were simply a string."

It can be imported from the package like so:

import { $NOW } from "timescape";

// or from a specific module
import { $NOW } from "timescape/react";

// Svelte import names prohibit a $ prefix, so it's renamed to NOW there
import { NOW } from "timescape/svelte";

placeholder on input elements

The placeholder attribute on the input elements is supported and will be used to display the placeholder text. Usually it's to indicate the expected format of the input, e.g. yyyy/mm/dd

step on input elements

The step attribute for input elements is supported and will be used to increment/decrement the values when the user uses the arrow keys. The default value is 1, but you can set it to any value you want. Also see snapToStep if you want to snap to the nearest step.

Preventing default keydown behavior

By default, timescape intercepts keydown events to enhance input behavior. If you want to handle keydown events yourself and prevent the default processing, you can do so by attaching your event handler during the capturing phase and calling preventDefault:

<input
  onKeyDownCapture={(e) => {
    if (e.key === 'Enter') {
      e.preventDefault()
    }
  }}
/>

Ranges

timescape supports ranges for the date/time inputs. This means a user can select a start and end. This is useful for things like booking systems, where you want to allow the user to select a range of dates.

This is achieved by using two timescape instances, one for the start and one for the end. You can set their options independently, and they return the respective options and update functions in the from and to objects.

Example usage (this works similar for all supported libraries):

import { useTimescapeRange } from "timescape/react";
// Use `createTimescapeRange` for Svelte

const { getRootProps, from, to } = useTimescapeRange({
  from: { date: new Date("2000-01-01") },
  to: { date: new Date() },
});

return (
  <div {...getRootProps()}>
    <div>
      <input {...from.getInputProps("days")} />
      <span>/</span>
      <input {...from.getInputProps("months")} />
      <span>/</span>
      <input {...from.getInputProps("years")} />
    </div>
    <div>
      <input {...to.getInputProps("days")} />
      <span>/</span>
      <input {...to.getInputProps("months")} />
      <span>/</span>
      <input {...to.getInputProps("years")} />
    </div>
  </div>
);

Anatomy & styling

The component is designed to be as un-opinionated as possible, so it doesn't come with any styling out of the box. You can style it however you want, but here are some tips to get you started.

This is how it could look like:

A typical anatomy of a timescape component may look like this:

HTML

<div class="timescape">
  <!-- Date inputs -->
  <input />
  <span class="separator">/</span>
  <input />
  <span class="separator">/</span>
  <input />

  <span class="separator">&nbsp;</span>

  <!-- Time inputs -->
  <input />
  <span class="separator">:</span>
  <input />
  <span class="separator">:</span>
  <input />
</div>

CSS

/**
 * Root element
 */
.timescape {
  display: flex;
  align-items: center;
  gap: 1px;
  width: fit-content;
  border: 1px solid #b2b2b2;
  padding: 5px;
  user-select: none;
  border-radius: 10px;
}

.timescape:focus-within {
  outline: 1px solid #8f47d4;
  border-color: #8f47d4;
}

/**
 * Date and time input elements
 */
.timescape input {
  /* This is an important style, as it ensures that the inputs have
  the same width regardless of the number of characters they contain. */
  font-variant-numeric: tabular-nums;
  height: fit-content;
  /* These are handled by the `:focus` selector */
  border: none;
  outline: none;
  cursor: default;
  user-select: none;
  box-sizing: content-box;
  /* For touch devices where input fields are not set to readonly */
  caret-color: transparent;

  /* For the calculation of the input width these are important */
  font-family: inherit;
  font-size: inherit;
  line-height: inherit;
}

.timescape input:focus {
  background-color: #8f47d4;
  color: #fff;
  border-radius: 6px;
  padding: 2px;
}

/**
 * Separator elements
 */
.timescape .separator {
  font-size: 80%;
  color: #8c8c8c;
  margin: 0;
}