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

[Question] Support for typed custom Directives #554

Open
Maxim-Mazurok opened this issue Apr 12, 2022 · 9 comments
Open

[Question] Support for typed custom Directives #554

Maxim-Mazurok opened this issue Apr 12, 2022 · 9 comments
Labels
question Further information is requested

Comments

@Maxim-Mazurok
Copy link
Contributor

🧐 Problem Description

As mentioned here, I'd like to have type safety for custom directives when using Vue 3 and JSX + Typescript (TSX).

💻 Sample code

import { defineCustomElement } from "vue";
import ClickOutside from "click-outside-vue3";

export default defineCustomElement({
  name: "Hello",
  directives: {
    clickOutside: ClickOutside.directive,
  },
  render() {
    return <div vClickOutside={() => console.log("clicked outside")}></div>;
  },
});

🚑 Other information

It works, but gives an annoying type error, saying:

Type '{ vClickOutside: () => void; }' is not assignable to type 'ElementAttrs<HTMLAttributes>'.
  Property 'vClickOutside' does not exist on type 'ElementAttrs<HTMLAttributes>'.ts(2322)

The current workaround that I applied is to use @ts-ignore (not that vite won't build it otherwise, but just to make the red line go away in VS Code), I didn't have time to figure out which interface I have to augment, potentially even patching node_modules using patch-package

@Maxim-Mazurok Maxim-Mazurok added the question Further information is requested label Apr 12, 2022
@Maxim-Mazurok
Copy link
Contributor Author

After posting I realize that this is probably less of a question, and more of a feature request, let me know what you think guys, thank you!

@funny-family
Copy link

@Maxim-Mazurok, this thing works but looks a bit messy and I'm still looking for a cleaner solution.

type VClickOutsideDirective = {
  'v-click-outside'?: Function;
};

declare module 'vue' {
  export interface HTMLAttributes extends VClickOutsideDirective {}
}

import { defineCustomElement } from "vue";
import ClickOutside from "click-outside-vue3";

export default defineCustomElement({
  name: "Hello",
  directives: {
    clickOutside: ClickOutside.directive,
  },
  render() {
    return <div v-click-outside={() => console.log("clicked outside")}></div>;
  },
});

@Maxim-Mazurok
Copy link
Contributor Author

Yeah, that's not ideal because TS will think that this directive can be used in all other components as well, even though they don't import this directive. But thanks for sharing, this is definitely an option!

@funny-family
Copy link

funny-family commented May 9, 2022

@Maxim-Mazurok since the option above was not as good, I found a new type-safe solution. (ignore any 🙃)

const focus = {
  mounted(el: any) {
    el.focus();
  },
};
...
import { withDirectives, openBlock } from 'vue';
...
<>
{withDirectives(
  // `openBlock` function must be called within `withDirectives` before the actual node to which the directives will be apply
  (openBlock(),
  (
    <input
      class="input"
      type="text"
    />
  )),
  [[focus]]
)}
</>

Also do not forget to register directive in component.

SomeComponent.directives = {
  focus
};

@Maxim-Mazurok
Copy link
Contributor Author

Oh wow, kudos for finding a type-safe solution, but I'm afraid it's not human-safe :D It took me a while to understand what's going on there. openBlock() isn't even documented outside of source code, so this seems very advance...

By the way, I think that it's still not completely type-safe. Now we know that directive exists, but we still don't know what kind of arguments it expects, I think that if I tried to use mine, like so:
withDirectives(/*...*/, [[clickOutside, () => console.log("clicked outside")]] it would expect [Directive, any] type, not [Directive, Function]
based on these types: https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/directives.ts#L72-L78

@funny-family
Copy link

It's type-safe, depends on what angle you look at. 🙃 From from detective perspective we can make it strong typed, but from component view, yes... we have some problems with withDirectives, but I have some thoughts on how this can be solved.

@funny-family
Copy link

funny-family commented May 25, 2022

@Maxim-Mazurok, it took me a long time, and I tried my best.

Util types:

/**
 * @see https://docs.microsoft.com/en-us/javascript/api/@azure/keyvault-certificates/requireatleastone?view=azure-node-latest
 */
export type RequireAtLeastOne<T> = {
  [K in keyof T]-?: Required<Pick<T, K>> &
    Partial<Pick<T, Exclude<keyof T, K>>>;
}[keyof T];

Util types related to createDirective function:

export type DirectiveModifier<M extends string> = Partial<
  Record<M, true | undefined>
>;

type DirectiveRegisterFunction = () => Record<string, Directive>;

type DirectiveUseFunction<V = any, A = string, M extends any = string> = (
  directiveArgument?: Partial<{
    value: V;
    arg: A;
    // @ts-expect-error <-- I gave up here ;(
    modifiers: M extends void ? void : DirectiveModifier<M>;
  }>
) => DirectiveArguments;

export interface _DirectiveBinding<
  V = any,
  A = string,
  M extends string = string,
  D = any
> {
  instance: ComponentPublicInstance | null;
  value: V;
  oldValue: V | null;
  arg?: A;
  modifiers: DirectiveModifier<M>;
  dir: ObjectDirective<D, V>;
}

export type DirectiveOption<
  DirectiveElement extends HTMLElement,
  DirectiveValue = any,
  DirectiveArg = string,
  DirectiveModifier extends string = string
> = {
  created?: (
    el: DirectiveElement,
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode<any, DirectiveElement>,
    prevVNode: null
  ) => void;

  beforeMount?: (
    el: DirectiveElement,
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode<any, DirectiveElement>,
    prevVNode: null
  ) => void;

  mounted?: (
    el: DirectiveElement,
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode<any, DirectiveElement>,
    prevVNode: null
  ) => void;

  beforeUpdate?: (
    el: DirectiveElement,
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode<any, DirectiveElement>,
    prevVNode: VNode<any, DirectiveElement>
  ) => void;

  updated?: (
    el: DirectiveElement,
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode<any, DirectiveElement>,
    prevVNode: VNode<any, DirectiveElement>
  ) => void;

  beforeUnmount?: (
    el: DirectiveElement,
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode<any, DirectiveElement>,
    prevVNode: null
  ) => void;

  unmounted?: (
    el: DirectiveElement,
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode<any, DirectiveElement>,
    prevVNode: null
  ) => void;

  getSSRProps?: (
    binding: _DirectiveBinding<DirectiveValue, DirectiveArg, DirectiveModifier>,
    vnode: VNode
  ) => Data | undefined;

  deep?: boolean;
};

Custom function that allows to register and use directive:

/**
 * @see https://vuejs.org/guide/reusability/custom-directives.html#introduction
 *
 * @example
 * type VFontDirectiveModifier = 'normal' | 'italic' | 'oblique';
 *
 * export const vFontDirective = createDirective<
 *  HTMLElement,
 *  number,
 *  void,
 *  VFontDirectiveModifier
 * >('font', {
 *   beforeMount(el, binding) {
 *     if (binding.modifiers.normal === true) {
 *       el.style.fontStyle = 'normal';
 *     } else if (binding.modifiers.italic === true) {
 *       el.style.fontStyle = 'italic';
 *     } else if (binding.modifiers.oblique === true) {
 *       el.style.fontStyle = 'oblique';
 *     }
 *
 *     el.style.fontSize = `${binding.value}px`;
 *   },
 *
 *   updated(el, binding) {
 *     el.style.fontSize = `${binding.value}px`;
 *   },
 * });
 *
 * ...
 *
 * SomeComponent.directives = {
 *   ...vFontDirective.register()
 * };
 *
 * ...
 *  {withDirectives(<h1>Heading</h1>, [
 *     vFontDirective.use({
 *       value: 40,
 *       modifiers: {
 *         italic: true
 *       }
 *     })
 *   ])}
 * ...
 */
export const createDirective = <
  DirectiveElement extends HTMLElement = HTMLElement,
  DirectiveValue = any,
  DirectiveArg = string,
  DirectiveModifier extends any = string
>(
  name: Readonly<string>,
  directiveObject: RequireAtLeastOne<
    DirectiveOption<
      DirectiveElement,
      DirectiveValue,
      DirectiveArg,
      // @ts-expect-error <-- And here too ;(
      DirectiveModifier
    >
  >
) => {
  const register: DirectiveRegisterFunction = () =>
    ({
      [name]: directiveObject
    } as any);

  const use: DirectiveUseFunction<
    DirectiveValue,
    DirectiveArg,
    DirectiveModifier
  > = (directiveArgument) =>
    [
      directiveObject,
      directiveArgument?.value,
      directiveArgument?.arg,
      directiveArgument?.modifiers
    ] as any[];

  return {
    register,
    use
  };
};

@Maxim-Mazurok
Copy link
Contributor Author

That looks pretty promising, good job 👍

@funny-family
Copy link

Here is an update without @ts-expect-error. I think it is complete now, not 100% perfect, but much better than it was before and what the vuejs doc showed us. (directives usage).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants