timescape

repository·main·Indexed 42 days ago

https://github.com/dan-lee/timescape

A headless toolkit for creating custom, accessible date and time input components. It provides logic, state management, and accessibility (ARIA spinbutton roles, keyboard navigation) while allowing developers to control the UI across frameworks including React, Vue, Svelte, Solid, Preact, and vanilla JavaScript.

Tokens
13.8K
Snippets
41
Records
51
Agent score
53%

What's inside timescape

  1. How date/time ranges work in timescape

    main

    To implement a date/time range (e.g., for booking systems), you use two separate timescape instances: one for the start (from) and one for the end (to).

    In framework integrations (like React), you use the useTimescapeRange hook. This hook provides a from and to object, each containing its own input helpers and ampm object. It also provides a root props helper to manage focus and accessibility for the entire range.

    In vanilla JavaScript, you can link two TimescapeManager instances using the marry function. This creates a relationship where from acts as the minimum bound and to acts as the maximum bound. It also enables focus wrapping between the two instances (e.g., moving past the last field of from focuses the first field of to).

    import { useTimescapeRange } from "timescape/react";
    import { useState } from "react";
    
    const [fromDate, setFromDate] = useState(new Date("2000-01-01"));
    const [toDate, setToDate] = useState(new Date());
    
    const { getRootProps, from, to } = useTimescapeRange({
      from: {
        date: fromDate,
        onDateChange: setFromDate,
      },
      to: {
        date: toDate,
        onDateChange: setToDate,
      },
    });
    
    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>
    );
  2. How timescape's headless architecture works

    main

    timescape is a headless library, meaning it manages the complex logic of date and time manipulation, accessibility (ARIA spinbutton roles, keyboard navigation), and state, while leaving the UI/styling entirely to you.

    To build a component, you use framework-specific hooks or managers that provide 'getters' or 'props' for a root container and individual input fields (e.g., years, months, days, hours, minutes, seconds). You decide the order and visual presentation by how you render these elements in your markup.

  3. Configure timescape with Controlled or Uncontrolled modes

    main

    The timescape component supports two modes of state management:

    • Controlled Mode: You manage the date state externally. You must provide the date prop and handle updates via the onDateChange callback. Use null to represent an empty date.
    • Uncontrolled Mode: The component manages its own state internally. You provide an initial value using the defaultDate prop.

    Important Distinction:

    • date={null}: Controlled mode with an empty date.
    • date={undefined}: Uncontrolled mode.

    When a user is mid-edit (e.g., a partially typed value or a cleared segment), the input maintains that local editing state. The parent owns the actual Date value, while the input owns the temporary edit state. Writing the same date back to a controlled input (e.g., if you reject a change) leaves the current edit alone, but writing a different date replaces the on-screen value.

    // Controlled Mode
    <Timescape 
      date={currentDate} 
      onDateChange={(newDate) => setCurrentDate(newDate)} 
    />
    
    // Uncontrolled Mode
    <Timescape 
      defaultDate={initialDate} 
    />
  4. How controlled and uncontrolled modes work in Timescape

    main

    Timescape framework integrations use the controlled/uncontrolled component pattern to manage date state. The mode is determined by the presence of the date prop during the first render:

    • Controlled mode: Pass a date prop (of type Date | null) and an onDateChange callback. You are responsible for managing the date in your own state (e.g., useState, ref, writable) and updating it within the callback.
    • Uncontrolled mode: Use the defaultDate prop for the initial value. The component will manage its own internal state.

    Important for Controlled Mode:

    • To represent an empty or incomplete date, use null. Using undefined will cause the component to switch to uncontrolled mode.
    • When editing, a controlled input will revert any changes that are not explicitly accepted by your state update. Writing the same date back leaves the edit in place, while writing a different date replaces the on-screen value.
    // Controlled mode
    const [date, setDate] = useState<Date | null>(new Date());
    const { getRootProps, getInputProps } = useTimescape({
      date,
      onDateChange: (nextDate) => setDate(nextDate),
    });
    
    // Uncontrolled mode
    const { getRootProps, getInputProps } = useTimescape({
      defaultDate: new Date(),
      onDateChange: (nextDate) => console.log(nextDate),
    });
  5. Use the $NOW value for dynamic date constraints

    main

    $NOW is a special constant used for minDate and maxDate. It represents the current date and time at the moment of user interaction and dynamically adjusts itself, so you do not need to manually update it to keep constraints current.

    Import it based on your framework:

    • Core/Generic: import { $NOW } from "timescape";
    • React/Preact: import { $NOW } from "timescape/react";
    • Svelte: import { NOW } from "timescape/svelte"; (Note: prefix removed for Svelte compatibility)
    import { $NOW } from "timescape";
    
    // Example usage in options
    const options = {
      minDate: $NOW
    };
  6. Migrate to Controlled/Uncontrolled pattern in Solid

    main

    In v0.9+, useTimescape no longer returns the options object or update function. Use createSignal to manage the date and pass the signal value to the date prop, updating the signal within onDateChange.

    const [date, setDate] = createSignal(new Date());
    const { getInputProps, getRootProps } = useTimescape({
      date: date(),
      onDateChange: (nextDate) => {
        setDate(nextDate);
      },
    });
    
    // To update the date externally:
    setDate(new Date());
  7. Migrate to Controlled/Uncontrolled pattern in React/Preact

    main

    In versions prior to v0.9, the useTimescape hook returned an options object and an update function. In v0.9+, these have been removed. You must now manage the date state yourself using useState and pass it back into the hook via date and onDateChange.

    // After v0.9 migration
    const [date, setDate] = useState<Date | null>(new Date());
    const { getRootProps, getInputProps } = useTimescape({
      date,
      onDateChange: (nextDate) => {
        console.log("Date changed to", nextDate);
        setDate(nextDate);
      },
    });
    
    // To update the date externally:
    setDate(new Date());
  8. Migrate to Controlled/Uncontrolled pattern in Svelte

    main

    In v0.9+, createTimescape no longer returns the options object. Use a writable store to manage the date and pass the current value to the date prop, updating the store within onDateChange.

    <script>
    const date = writable(new Date());
    const { inputProps, rootProps } = createTimescape({
      date: $date,
      onDateChange: (nextDate) => {
        date.set(nextDate);
      },
    });
    
    // To update the date externally:
    date.set(new Date());
    </script>
  9. Install timescape

    main

    You can install timescape using your preferred package manager. The library is lightweight (~5.3 kB min+gzip for the core) and has no runtime dependencies.

    # pnpm
    pnpm add timescape
    
    # yarn
    yarn add timescape
    
    # npm
    npm install --save timescape
  10. Prevent default keydown behavior in timescape

    main

    Timescape intercepts keydown events to enhance input behavior. To handle your own keydown events and prevent timescape from processing them, attach your handler during the capturing phase and call e.preventDefault().

    <input
      onKeyDownCapture={(e) => {
        if (e.key === "Enter") {
          e.preventDefault();
        }
      }}
    />
  11. Style the timescape component

    main

    Timescape is unstyled by default. To achieve a standard look, use a flex container for the root and font-variant-numeric: tabular-nums on inputs to ensure consistent widths.

    Recommended HTML Structure

    <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>

    Key CSS Tips

    • Use .timescape:focus-within to highlight the entire component when an input is active.
    • Set caret-color: transparent on inputs for touch devices where fields might not be readonly.
    • Use box-sizing: content-box and border: none; outline: none; on inputs to manage custom focus states easily.
  12. Migrate to Controlled/Uncontrolled pattern in Vue

    main

    In v0.9+, useTimescape no longer returns the options object. Use a ref to manage the date and pass it to the date prop, updating it via onDateChange.

    <script setup>
    const date = ref(new Date());
    const { registerElement, registerRoot } = useTimescape({
      date,
      onDateChange: (nextDate) => {
        date.value = nextDate;
      },
    });
    
    // To update the date externally:
    date.value = new Date();
    </script>