Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useTimescape / useTimescapeRange should be Reactive by default #40

Open
junwen-k opened this issue Oct 30, 2024 · 4 comments
Open

useTimescape / useTimescapeRange should be Reactive by default #40

junwen-k opened this issue Oct 30, 2024 · 4 comments

Comments

@junwen-k
Copy link

Recently I've just came across this library, exactly what I was looking for a long time, awesome! However, there is a slight problem with the React (and possibly other frameworks) integration.

Problem

Currently, useTimescape is not declarative by default, and to sync the state with external sources, we need to use useEffect to handle it manually.

const [date, setDate] = React.useState(new Date());

const { getRootProps, getInputProps, update } = useTimescape({
  date, // This is the default value (initial value) and is not reactive by default, which is not ideal in my opinion.
  onValueChange: setDate,
});

// In controlled mode, we need to add this manually to sync changes in `date` from an external source.
React.useEffect(() => {
  update((prevOptions) => ({
    ...prevOptions,
    date,
  }));
}, [date]);

// We need to repeat `useEffect` for each individual option if any option changes.

Suggestion

To better align with the declarative nature of React, useTimescape should be reactive by default, without requiring users to call update() imperatively. For instance:

// `update`, `options`, `_manager` don’t need to be exposed.
const { getRootProps, getInputProps } = useTimescape({
  // Inspired by Radix's API
  value, // or `date` for controlled mode
  defaultValue, // or `defaultDate` for uncontrolled mode
  onValueChange, // or `onDateChange`, called with the new value on change
  ...options, // All options should be reactive by default
});

This change aligns well with the React ecosystem API, making it more predictable and declarative.

Remark

Some popular libraries that uses the method that I described

This proposed change could also potentially apply to other frameworks like Solid.


Thank you so much for this awesome library.

@junwen-k junwen-k changed the title useTimescape should be Reactive by default useTimescape / useTimescapeRange should be Reactive by default Oct 30, 2024
@dan-lee
Copy link
Owner

dan-lee commented Oct 31, 2024

Hey @junwen-k, thanks for flagging this! I agree that this would be a good change for the React implementation. However, I am not so sure about the other integrations (because I haven't worked intensively with them before).

I don't know if I'll be able to get to this soon, but if you want to shoot a PR I'd appreciate it. No worries if not :)

@lsmurray
Copy link
Contributor

lsmurray commented Dec 17, 2024

Here is a user land version of useTimescape that is reactive. Hopefully this is a reasonable starter for a PR. If I have time I'll try to add a PR as well.

import React from "react";
// eslint-disable-next-line import-x/no-named-as-default
import TimescapeManager from "timescape";
import type { DateType, Options } from "timescape/react";

export type TimepickerOptions = Omit<Options, "date" | "onChangeDate"> & {
  defaultDate?: Date | null;
  date?: Date | null;
  onChangeDate?: (nextDate: Date | null) => void;
};

// rewritten from https://github.com/dan-lee/timescape/blob/main/packages/lib/src/integrations/react.ts
// to support reactivity
// see https://github.com/dan-lee/timescape/issues/40
export const useTimepicker = (options: TimepickerOptions = {}) => {
  const { date, onChangeDate, defaultDate, ...rest } = options;
  const [manager] = React.useState(
    () => new TimescapeManager(date ?? defaultDate ?? undefined, rest)
  );
  const onChangeDateRef = React.useRef(onChangeDate);
  const isUpdatingFromProps = React.useRef(false);
  React.useLayoutEffect(() => {
    onChangeDateRef.current = onChangeDate;
  }, [onChangeDate]);

  React.useEffect(() => {
    // this is to work around StrictMode shenanigans
    manager.resync();

    return () => {
      manager.remove();
    };
  }, [manager]);

  React.useEffect(() => {
    return manager.on("changeDate", (nextDate) => {
      // only call onChangeDate if the change came from the input/manager
      // do not call onChangeDate if the change came from the date prop
      if (
        !isUpdatingFromProps.current &&
        nextDate?.getTime() !== date?.getTime()
      ) {
        onChangeDateRef.current?.(nextDate ?? null);
      }
    });
  }, [date, manager]);

  const dateTimestamp = date?.getTime();
  const managerDateTimestamp = manager.date?.getTime();
  const isDateUndefined = date === undefined;
  React.useEffect(() => {
    if (dateTimestamp !== managerDateTimestamp && !isDateUndefined) {
      // disable onChangeDate while updating from props
      isUpdatingFromProps.current = true;
      manager.date = dateTimestamp ?? undefined;
      isUpdatingFromProps.current = false;
    }
  }, [dateTimestamp, isDateUndefined, manager, managerDateTimestamp]);

  React.useEffect(() => {
    if (!isDateSame(rest.minDate, manager.minDate))
      manager.minDate = rest.minDate;
    if (!isDateSame(rest.maxDate, manager.maxDate))
      manager.maxDate = rest.maxDate;
    if (rest.hour12 !== manager.hour12) manager.hour12 = rest.hour12;
    if (rest.digits !== manager.digits) manager.digits = rest.digits;
    if (rest.wrapAround !== manager.wrapAround)
      manager.wrapAround = rest.wrapAround;
    if (rest.snapToStep !== manager.snapToStep)
      manager.snapToStep = rest.snapToStep;
    if (rest.wheelControl !== manager.wheelControl)
      manager.wheelControl = rest.wheelControl;
    if (rest.disallowPartial !== manager.disallowPartial)
      manager.disallowPartial = rest.disallowPartial;
  }, [
    manager,
    rest.digits,
    rest.disallowPartial,
    rest.hour12,
    rest.maxDate,
    rest.minDate,
    rest.snapToStep,
    rest.wheelControl,
    rest.wrapAround,
  ]);

  // non-prod warnings
  if (!import.meta.env.PROD) {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const wasControlled = React.useRef<boolean>(date !== undefined);

    // eslint-disable-next-line react-hooks/rules-of-hooks
    React.useEffect(() => {
      // Check for simultaneous defaultDate and date props
      if (defaultDate != null && date !== undefined) {
        // eslint-disable-next-line no-console
        console.warn(
          "Warning: TimePicker component received both `date` and `defaultDate` props. " +
            "`date` will take precedence. Remove `defaultDate` when using TimePicker as a controlled component."
        );
      }

      const isControlled = date !== undefined;
      const wasControlledValue = wasControlled.current;

      if (isControlled !== wasControlledValue) {
        const mode = isControlled ? "controlled" : "uncontrolled";
        // eslint-disable-next-line no-console
        console.warn(
          `Warning: TimePicker component is changing from ${
            wasControlledValue ? "controlled" : "uncontrolled"
          } to ${mode}. ` +
            `This may lead to unexpected behavior. Decide between controlled or uncontrolled for the entire lifecycle.`
        );
      }

      wasControlled.current = isControlled;
    }, [date, defaultDate]);
  }

  return {
    _manager: manager,
    getInputProps: (
      type: DateType,
      opts?: {
        ref?: React.MutableRefObject<HTMLInputElement | null>;
        autofocus?: boolean;
      }
    ) => ({
      ref: (element: HTMLInputElement | null) => {
        if (!element) return;
        manager.registerElement(element, type, opts?.autofocus);
        if (opts?.ref) opts.ref.current = element;
      },
    }),
    getRootProps: () => ({
      ref: (element: HTMLElement | null) =>
        element && manager.registerRoot(element),
    }),
  } as const;
};

// helper for comparing min/max dates
function isDateSame(
  a: Date | "$NOW" | undefined,
  b: Date | "$NOW" | undefined
) {
  if (a instanceof Date && b instanceof Date)
    return a.getTime() === b.getTime();
  return a === b;
}

@dan-lee
Copy link
Owner

dan-lee commented Dec 25, 2024

Thanks for that! I haven't got around to it yet. PR would be greatly appreciated

@lsmurray
Copy link
Contributor

yea understood - I'm reimplementing the range with reactivity now that STOP_EVENT_PROPAGATION is exposed then I'll try to PR my changes back along with tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants