@olicoad/neodt

A framework- and datetime-library-independent, locale-aware datetime input.
1,878 1
Install
npm install @olicoad/neodt
Latest Version:0.4.0
Vue:3
License:MIT
Last Updated:Sep 29, 2026
Links: GitHub  ·  npm
Maintainer: olicoad

neodt

A locale-aware, keyboard-editable segmented date and time input.

Links: npm | GitHub | Issues | Demo

Install

pnpm add @olicoad/neodt

Frameworks

neodt uses Mitosis to support many frontend frameworks from one shared implementation. Choose your framework and datetime library independently, and install only the packages you use.

See Frameworks for supported frameworks, installation, and usage examples.

Vanilla TypeScript usage

The package root provides a plain DOM API with a bundled renderer. No framework or JSX setup is required. The Library choice None means Native Temporal; your runtime must provide it.

import createNeodt from "@olicoad/neodt";

const container = document.querySelector<HTMLElement>("#date")!;
const neodt = createNeodt(container, {
  referenceTime: Temporal.Now.zonedDateTimeISO("Australia/Sydney"),
  onValueChange: (value) => console.log(value), // Temporal.ZonedDateTime | null
});

// Change options later without creating another control.
// Options you omit keep their current values.
neodt.update({ locale: "en-AU", formatOptions: { hour12: false } });

// Call this from your app when removing this form, dialog, or page.
// Removing HTML alone does not stop observers or pending animation work.
function removeDateField() {
  neodt.destroy(); // Remove the control and release its listeners and observers.
  container.remove(); // Remove the surrounding container if no longer needed.
}

Every registered datetime library is available directly under the package: @olicoad/neodt/luxon, @olicoad/neodt/spacetime, @olicoad/neodt/temporal-polyfill, and the other library entries. Install only your chosen datetime package. Use @olicoad/neodt/generic for a custom adapter. Values and callbacks retain the selected library's types.

The returned handle exposes element, update(partialOptions), and destroy(). Updates merge options; omitted options keep their current values, and passing undefined removes an optional option. The merge is shallow: passing formatOptions replaces the whole formatting object. Omit value for uncontrolled editing, or pass value and call neodt.update({ value }) from onValueChange for controlled editing.

Call neodt.destroy() in your app's cleanup code when the form, dialog, or page containing the control is removed or replaced. Removing the container from the DOM does not call destroy() automatically. The control also owns event listeners, a resize observer, subscriptions, and scheduled animation work. Removing HTML alone does not dispose of these resources. destroy() removes the listeners, disconnects the observer, unsubscribes from updates, and cancels pending animation work, preventing unnecessary background work and retained resources as UI is repeatedly created and removed. Destroy the control before removing its container. It removes only that control's nodes, leaving the container and any other content intact; remove the container separately if needed, as shown above. Hiding a dialog that you intend to reuse does not require destroying its control. Calling destroy() again is harmless; updating a destroyed control throws, so create a new instance if you need it again.

Vanilla uses ordinary DOM and the shared stylesheet. It mounts in the browser; imports and parser utilities are safe on the server. The framework components provide server rendering and hydration. The demo landing page defaults to Vanilla with None (Native Temporal), loading temporal-polyfill globally for browser compatibility.

Solid usage

Install solid-js alongside neodt for the Solid component.

Each framework root uses Native Temporal. Your runtime must provide Temporal; see Datetime libraries for polyfills and other libraries.

import { createSignal } from "solid-js";
import Neodt from "@olicoad/neodt/solid";

function Appointment() {
  const [value, setValue] = createSignal<Temporal.ZonedDateTime | null>(null);

  return (
    <Neodt
      referenceTime={Temporal.Now.zonedDateTimeISO("Australia/Sydney")}
      locale="en-AU"
      value={value()}
      onValueChange={setValue}
    />
  );
}

Datetime adapters

neodt supports different datetime libraries through dedicated package entries. The import path selects the library for both Neodt and parseNaturalDate, including their native value and timezone types. Only the selected library is loaded; datetime packages are optional peer dependencies.

See Datetime libraries for available integrations, installation, examples, and timezone configuration. Use /generic with an explicit adapter for custom configuration.

Custom adapters

import Neodt, { parseNaturalDate, type DateAdapter } from "@olicoad/neodt/solid/generic";

import { createDateFnsAdapter } from "@olicoad/neodt/solid/date-fns";
import { toDate, constructNow } from "date-fns";

const adapter: DateAdapter<Date> = createDateFnsAdapter(toDate, { zone: "UTC" });
const referenceTime = constructNow(0);
const tomorrow = parseNaturalDate("tomorrow", { adapter, referenceTime });
<Neodt adapter={adapter} referenceTime={referenceTime} defaultValue={tomorrow} />;

The generic entry requires an explicit adapter and exports NeodtProps<T, TZone> and NaturalDateParseOptions<T, TZone>. Use it with your own adapter or a configured built-in factory. It has no datetime dependencies.

Reuse a built-in factory as above, or implement DateAdapter<T, TZone> by delegating these non-mutating operations to your library:

Methods Responsibility
toEpochMilliseconds, fromEpochMilliseconds, getZone Preserve instants and native zones at the boundary.
getFields, fromFields, setFields Read, construct, and edit ISO/Gregorian fields (months 1–12).
add, startOf Calendar/elapsed arithmetic and minute/day/month boundaries.
getWeekday, getDaysInMonth Monday-based weekday (1–7) and library-provided month length.
getOffset Minutes east of UTC at the selected instant, used by showTimeOffset.
setZoneId Convert an instant to a zone explicitly named in parser input.
createFormatter Reusable locale presentation; returns formatToParts(value).
isOffsetFixed (optional) Let layout measurement know the offset cannot vary by date.

createFormatter(zone, locale, options) returns an object with formatToParts(value). The core reuses these objects across values, including DST transitions, using a weakly keyed adapter cache with at most 32 entries per adapter. Entries are keyed by zone (object identity for opaque zones), locale, and format options, and the least recently used entry is evicted when the limit is reached.

Zones may be strings or opaque objects. Custom Temporal implementations must provide ZonedDateTime.from and the standard value operations as well as Instant.fromEpochMilliseconds.

Calendar arithmetic, month lengths, UTC offsets, and timezone resolution belong to the selected library. Every adapter follows its library's semantics for calendar-day versus elapsed-hour arithmetic, month/year overflow, and ambiguous or nonexistent local times.

neodt parses input syntax and checks field ranges using the library's month length; it does not implement timezone transitions or Gregorian arithmetic. Native JavaScript Date access is confined to adapters whose library APIs require it (date-fns and @internationalized/date). Luxon supplies its own format parts. Temporal, date-fns, Day.js, and @internationalized/date format the actual instant and zone with Intl. Moment and Spacetime retain their own timezone offsets when formatting because their timezone databases can differ from Intl. The shared core does not construct or manipulate native dates. Offsetless ISO input is interpreted in the reference zone.

Documentation

API

referenceTime: T is required; T is determined by the import path. Only /generic also requires adapter: DateAdapter<T, TZone>. The reference provides the timezone, is used to fill empty segments, and determines how two-digit years are interpreted. Selected values are always normalized to this zone.

Prop Description
value?: T | null Controlled value. Pass null to clear the field.
defaultValue?: T Initial uncontrolled value.
onValueChange?: (value: T | null) => void Called after a complete value is changed or cleared.
locale?: Intl.LocalesArgument Locale for segment order and labels. Defaults to the browser locale.
formatOptions?: Intl.DateTimeFormatOptions Formatting options, including hour12 and hourCycle.
showTimeOffset?: boolean Shows the selected date's UTC offset.
readonly?: boolean Displays a value without allowing edits.
disabled?: boolean Prevents focus and editing.

Controlled updates from the parent replace the displayed draft; setting value to null clears every segment. During editing, incomplete segments emit null while retaining the local draft. Echoing emitted values through onValueChange preserves ongoing numeric entry.

Lit exports a LitElement class from @olicoad/neodt/lit and each datetime entry. Register it with customElements.define("my-neodt", Neodt) and pass a typed object using Lit’s .props=${props} binding. Replace the props object to update it. Native attributes belong to the custom-element host; its shadow root contains the editor and its styles.

For other frameworks, native span attributes and ARIA attributes are forwarded to the root element using the selected framework’s conventions. This is a JavaScript-managed SPA control and does not provide native form submission.

The control supports mouse, touch, and keyboard editing: Arrow Left/Right move between segments, Arrow Up/Down change a segment, and numeric input replaces numeric segments. Space opens the native picker; @ opens natural-language input. Natural-language input accepts a single point in time, such as tomorrow 9:30am, in 2 hours, or 5pm America/New_York; date ranges are not supported.

Styles

Vanilla installs its default styles before application styles when mounted. Lit includes its styles inside its shadow root. Other component entries import the component CSS, so Vite and standard Solid build setups need no extra configuration. For applications that exclude dependency side effects, or that centralize stylesheet imports, import the public stylesheet explicitly:

import "@olicoad/neodt/style.css";
import Neodt from "@olicoad/neodt/solid";

Set theme variables on a class applied to the component, after the default stylesheet. Defaults are declared on the root itself, so ancestor variables alone do not override them:

.booking-time {
  --datetime-neo-background: #15251d;
  --datetime-neo-foreground: #f6f0dd;
  --datetime-neo-border: #789271;
  --datetime-neo-focus: #d4a529;
  --datetime-neo-highlight-foreground: #fff;
}

The root has the datetime-neo class. Useful internal hooks include datetime-neo__segment, datetime-neo__separator, and datetime-neo__trigger.

Lit themes use native CSS nesting and exposed parts. Put the class on the custom element:

.theme-paper {
  &::part(root) {
    --datetime-neo-background: #fffcf5;
    border-radius: 0.2rem;
    width: 100%;
  }
  &::part(editor) {
    padding: 0 3px;
  }
}

Use :state(readonly)::part(root) for the readonly root or :state(readonly):state(wrapped)::part(root) when it is also wrapped. Lit exposes readonly, disabled, wrapped, empty, natural, overflowing, time-offset, and layout-changing as custom states; parts identify internal elements. The styling gallery shows Shadow DOM parts for Lit and light DOM selectors for other frameworks. Lit SSR uses @lit-labs/ssr; load @lit-labs/ssr-client/lit-element-hydrate-support.js before defining elements on the client, and defer hydration until .props is set.

Utilities

The natural-language parser and completion helper are public exports for building adjacent UI:

import {
  getNaturalDateCompletions,
  parseNaturalDate,
  type NaturalDateCompletion,
  type NaturalDateParseOptions,
} from "@olicoad/neodt/solid";

parseNaturalDate(value, { referenceTime, zone?, locale? }) returns the selected implementation’s value type or undefined. zone defaults to the reference’s zone and accepts the adapter’s native zone type. Import the parser and NaturalDateParseOptions from the same entry as your component. The /generic parser additionally requires adapter and exports NaturalDateParseOptions<T, TZone>. getNaturalDateCompletions(value, maximum?) returns completion labels and replacement text.

Development

Use Node.js 24 or later and pnpm 11 or later.

pnpm install
pnpm dev

To extend the project, see adding datetime libraries and adding frameworks.

pnpm generate compiles the shared .lite.tsx components for every registered framework. Development and build commands run it automatically; generated files are not committed.

The demo is served at http://localhost:3000. Validate changes with:

pnpm exec playwright install --with-deps chromium firefox webkit
pnpm check:release
pnpm pack --dry-run

pnpm check runs non-mutating formatting, lint, type, unit/SSR, docs builds, and isolated consumer type/bundle checks. pnpm test:browser checks real layouts in Chromium, Firefox, and WebKit. See layout testing for CSS invariants and debugging, and release preparation for the v1 checklist.

Publishing And Deployment

GitHub Actions publishes the npm package when a pushed v* tag exactly matches package.json's version. Vercel deploys the linked GitHub repository: commits to main create production deployments and pull requests create preview deployments.

Release procedure

  1. Set the intended version in package.json and update pnpm-lock.yaml with pnpm install --lockfile-only.
  2. Run pnpm check:release && pnpm pack --dry-run.
  3. Commit the release, push it to main, then create and push an annotated v<version> tag matching package.json exactly.

The Publish npm package workflow verifies the tag/version match, validates the package, and publishes it with npm provenance. Vercel handles demo deployments through its Git integration.

Further Development

See the bundle size and runtime investigation for the Solid + Luxon comparison with 0.2.0, tree-shaking checks, and reproduction commands.

Some features that could be interesting to explore:

Min/Max Datetimes

Enforce picked datetimes between optional minimum and maximum props.

Precision and Step Size

Support configurable precision and step size:

  • Seconds
  • Milliseconds
  • Date-only values
  • Step size

Todo

  • fix all failing tests make them not flaky
  • update landing page hero section
  • reduce the full control's bundle contribution toward <10 KiB while preserving runtime performance

Related Packages

@tanstack/table-core

Headless UI for building powerful tables & datagrids for TS/JS.

656,578,845 28,508
timescape

A flexible, headless date and time input library for JavaScript. Provides tools...

888,584 230
@zamiru/timescape

A flexible, headless date and time input library for JavaScript. Provides tools...

8,885 230
@arclux/prism

Auto-generate framework wrappers (React, Vue, Svelte, Angular, Solid, Preact) an...

6,845 1
@symbiote-native/slider

Third-party native Slider view wrapped for SymbioteNative — one framework-agnost...

5,749 72

Version History

Version Released Vue License
0.4.0 ^3.5.0 MIT
0.3.1 MIT
0.2.0 MIT
0.1.8 MIT
0.1.7 MIT