Skip to content

Commit

Permalink
feat(programmatic): add programmatic feature (#944)
Browse files Browse the repository at this point in the history
  • Loading branch information
mlmoravek authored Jun 10, 2024
1 parent 7a103f1 commit 2840a7e
Show file tree
Hide file tree
Showing 9 changed files with 236 additions and 4 deletions.
1 change: 1 addition & 0 deletions packages/oruga/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from "./menu";
export * from "./modal";
export * from "./notification";
export * from "./pagination";
export * from "./programmatic";
export * from "./radio";
export * from "./select";
export * from "./skeleton";
Expand Down
6 changes: 3 additions & 3 deletions packages/oruga/src/components/notification/examples/index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
<script setup lang="ts">
import Programmatically from "./programmatically.vue";
import ProgrammaticallyCode from "./programmatically.vue?raw";
import Base from "./base.vue";
import BaseCode from "./base.vue?raw";
Expand All @@ -13,6 +10,9 @@ import UseTypesCode from "./use-types.vue?raw";
import AddCustomButtons from "./add-custom-buttons.vue";
import AddCustomButtonsCode from "./add-custom-buttons.vue?raw";
import Programmatically from "./programmatically.vue";
import ProgrammaticallyCode from "./programmatically.vue?raw";
</script>

<template>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script setup lang="ts">
// @ts-expect-error Examples are loaded differently.
import { useOruga } from "../../../../dist/oruga";
import NotificationForm from "./_notification-form.vue";
const oruga = useOruga();
async function component(): Promise<void> {
const instance = oruga.programmatic.open({
component: NotificationForm,
target: "#notification",
});
// wait until the notification got closed
const result = await instance.promise;
oruga.notification.open({
duration: 5000,
message: "Modal dialog returned " + JSON.stringify(result),
variant: "info",
position: "top",
closable: true,
});
}
</script>

<template>
<section>
<o-button
label="Launch notification (component)"
variant="warning"
size="medium"
@click="component" />
</section>
</template>

<style lang="scss">
.toast-notification {
margin: 0.5em 0;
text-align: center;
box-shadow:
0 1px 4px rgb(0 0 0 / 12%),
0 0 6px rgb(0 0 0 / 4%);
border-radius: 2em;
padding: 0.75em 1.5em;
pointer-events: auto;
color: rgba(0, 0, 0, 0.7);
background: #ffdd57;
}
</style>
2 changes: 2 additions & 0 deletions packages/oruga/src/components/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Menu from "./menu";
import Modal from "./modal";
import Notification from "./notification";
import Pagination from "./pagination";
import Programmatic from "./programmatic";
import Radio from "./radio";
import Select from "./select";
import Skeleton from "./skeleton";
Expand Down Expand Up @@ -45,6 +46,7 @@ export {
Modal,
Notification,
Pagination,
Programmatic,
Radio,
Select,
Skeleton,
Expand Down
145 changes: 145 additions & 0 deletions packages/oruga/src/components/programmatic/Programmatic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
createVNode,
defineComponent,
render,
getCurrentInstance,
onMounted,
onUnmounted,
type Component,
type ComponentInternalInstance,
type VNode,
} from "vue";

import InstanceRegistry from "@/utils/InstanceRegistry";
import { VueInstance } from "@/utils/plugins";
import { isElement, removeElement } from "@/utils/helpers";
import { isClient } from "@/utils/ssr";

import type { ProgrammaticExpose } from "@/types";

declare module "../../index" {
interface OrugaProgrammatic {
programmatic: typeof Programmatic;
}
}

type ProgrammaticComponentProps = {
/**
* Component to be injected.
* Terminate the component by emitting a 'close' event — emits('close')
*/
component: string | Component;
/**
* Props to be binded to the injected component.
* Both attributes and properties can be used in props.
* Vue automatically picks the right way to assign it.
* `class` and `style` have the same object / array value support like in templates.
* Event listeners should be passed as onXxx.
* @see https://vuejs.org/api/render-function.html#h
*/
props?: Record<string, any>;
/** Callback function to call on close event */
onClose?: (...args: unknown[]) => void;
/** Destroy component on close event */
destroyable?: boolean;
/**
* This is used internally for programmatic usage
* @ignore
*/
instances: InstanceRegistry<ComponentInternalInstance>;
};

const ProgrammaticComponent = defineComponent(
(props: ProgrammaticComponentProps, { expose }) => {
// getting a hold of the internal instance in setup()
const vm = getCurrentInstance();

let resolve: (value?: unknown) => void = null;
const promise = new Promise((p1) => {
resolve = p1;
});

onMounted(() => {
props.instances.add(vm);
});

onUnmounted(() => {
props.instances.remove(vm);
resolve.apply(null);
});

function close(...args: unknown[]): void {
// call handler if given
if (typeof props.onClose === "function")
props.onClose.apply(null, args);

if (typeof props.destroyable === "undefined" || props.destroyable) {
// use timeout for any animation to complete before destroying
setTimeout(() => {
const element = vm.vnode.el as Element;
// remove the component from the container or the body tag
if (element) {
if (isClient)
window.requestAnimationFrame(() =>
removeElement(element),
);
else removeElement(element);
}
});
}
}

/** expose public functionalities for programmatic usage */
expose({ close, promise });

// render given component
return (): VNode =>
createVNode(props.component, { ...props.props, onClose: close });
},
{ props: ["component", "props", "onClose", "destroyable", "instances"] },
);

const instances = new InstanceRegistry<ComponentInternalInstance>();

export type ProgrammaticProps = {
/**
* Specify a target the component get rendered into.
* @default `body`
*/
target?: string | HTMLElement;
} & Omit<ProgrammaticComponentProps, "instances">;

const Programmatic = {
open(props: ProgrammaticProps): ProgrammaticExpose {
const target =
typeof props.target === "string"
? document.querySelector<HTMLElement>(props.target)
: isElement(props.target)
? (props.target as HTMLElement)
: document.body;

// cache container
const container = document.createElement("div");

// create dynamic component
const vnode = createVNode(ProgrammaticComponent, {
...props,
instances: instances,
});
vnode.appContext = VueInstance._context;

// render a new vue instance into the cache container
render(vnode, container);

// place rendered elements into target element
target.append(...container.childNodes);

// return exposed functionalities
return vnode.component.exposed as ProgrammaticExpose;
},
closeAll(...args: any[]): void {
instances.walk((entry) => entry.exposed.close(...args));
},
};

export default Programmatic;
17 changes: 17 additions & 0 deletions packages/oruga/src/components/programmatic/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { App, Plugin } from "vue";

import Programmatic from "./Programmatic";

import { registerComponentProgrammatic } from "@/utils/plugins";

/** export programmatic specific types */
export type { ProgrammaticProps } from "./Programmatic";

/** export programmatic plugin */
export default {
install(app: App) {
registerComponentProgrammatic(app, "programmatic", Programmatic);
},
} as Plugin;

// no component export here
3 changes: 3 additions & 0 deletions packages/oruga/src/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,9 @@ Meaning that the container should be fixed. */
/** Pagination size */
size: string;
}>;
programmatic?: ComponentConfigBase &
Partial<{
}>;
radio?: ComponentConfigBase &
Partial<{
/** Class of the native input element */
Expand Down
2 changes: 1 addition & 1 deletion packages/oruga/src/types/programmatic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ export interface ProgrammaticInstance<T = ComponentInternalInstance> {
*/
export interface ProgrammaticExpose {
close(...args: any[]): void;
promise: Promise<unknown>;
promise: Promise<void>;
}
14 changes: 14 additions & 0 deletions packages/oruga/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ export function isEqual(valueA: unknown, valueB: unknown): boolean {
return false;
}

/**
* Returns true if it is a DOM element
* @source https://stackoverflow.com/questions/384286/how-do-you-check-if-a-javascript-object-is-a-dom-object
*/
export function isElement(o: any): boolean {
return typeof HTMLElement === "object"
? o instanceof HTMLElement //DOM2
: o &&
typeof o === "object" &&
o !== null &&
o.nodeType === 1 &&
typeof o.nodeName === "string";
}

/**
* Clone an obj with Object.assign
*/
Expand Down

0 comments on commit 2840a7e

Please sign in to comment.