Skip to content

Commit

Permalink
feat(programmatic): make programmatic components devtools compatible (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
mlmoravek authored Feb 9, 2025
1 parent 9ad96f7 commit 40dd968
Show file tree
Hide file tree
Showing 12 changed files with 120 additions and 147 deletions.
2 changes: 1 addition & 1 deletion packages/docs/components/Notification.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ It is designed to mimic the push notifications that have been popularized by mob
| icon | Icon name to use | string | - | |
| iconPack | Icon pack to use | string | `mdi`, `fa`, `fas and any other custom icon pack` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>notification: {<br>&nbsp;&nbsp;iconPack: undefined<br>}</code> |
| iconSize | Icon size | string | `small`, `medium`, `large` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>notification: {<br>&nbsp;&nbsp;iconSize: "large"<br>}</code> |
| message | Message text (can contain HTML), unnecessary when default slot is used | string \| string[] | - | |
| message | Message text, unnecessary when default slot is used | string | - | |
| override | Override existing theme classes completely | boolean | - | |
| position | Which position the notification will appear when programmatically | "bottom-left" \| "bottom-right" \| "bottom" \| "top-left" \| "top-right" \| "top" | `top-right`, `top`, `top-left`, `bottom-right`, `bottom`, `bottom-left` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>notification: {<br>&nbsp;&nbsp;position: "top"<br>}</code> |
| type | Type (color) of the notification | string | `info`, `success`, `warning`, `danger` | |
Expand Down
32 changes: 19 additions & 13 deletions packages/docs/documentation/composables.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,16 @@ type ProgrammaticExpose = {
## Component Programmatic {#programmatic}
Oruga comes with a component that is only available programmatically. This component can be used to mount **any** custom component programmatically, using the [Vue render function](https://vuejs.org/api/render-function.html#render-function-apis) and [Creating Vnodes](https://vuejs.org/guide/extras/render-function.html#render-function-recipes).

The component works as follows: The programmatic component renders a wrapper component in a sperate shadow Vue instance. The separate Vue instance will have the same context object as the current one. The provided component is then rendered in a wrapper component that handles the Vue lifecycle events of the provided component.
The rendered component is then extracted from the shadow Vue instance and placed into the target container of the real DOM instance.
By closing the instance of the wrapper component, for example by calling `oruga.programmatic.close()` from outside, or by firing a `close` event from inside the provided component, the wrapper component and the shadow Vue instance will be destroyed and DOM will be cleaned up.
The component works like this:
The programmatic component creates a new, separate Vue instance, with a wrapper component as root element.
The new Vue instance can be seen in the [Vue Devtools](https://devtools.vuejs.org/) with the name `ProgrammaticApp`.
The separate Vue instance will have the same context object as the current one and will be rendered into a `div` in the target DOM container (by default, it is rendered into the `body` element).
The provided component is then rendered into the wrapper component, which handles the Vue lifecycle events of the provided component.
Closing the instance of the wrapper component, for example by calling `oruga.programmatic.close()` from outside, or by firing a `close` event from inside the provided component, will destroy the wrapper component and the new Vue instance, and clean up the DOM.

> ***Note:*** When using the programmatic component, you may experience some DX issues if you run the Vue Devtools and inspect the programmatic component.
> ***Note:*** For performance reasons, be careful not to open too many programmatic components at once, each of which will create a separate Vue instance.
By adding this component using the main Oruga plugin or the dedicated `ComponentProgrammatic` plugin, the component adds an interface `programmatic` to the `useOruga()` composable and provides the `ComponentProgrammatic` object export, but it does not have a Vue component to mount directly.
By adding this component using the main Oruga plugin or the dedicated `ComponentProgrammatic` plugin, the component adds an interface `programmatic` to the `useOruga()` composable and provides the `ComponentProgrammatic` object export, but it does not have a Oruga component.

```typescript
import { useOruga } from "@oruga-ui/oruga-next";
Expand All @@ -191,12 +194,11 @@ const slot = "My default slot content";
oruga.programmatic.open(
MyComponent,
{
target: document.body, // target the component get rendered into
target: document.body, // target container the programmatic component get rendered into
appId: "programmatic-app", // HTML #id of the app div rendered into the target container
props: { ... }, // component specific props
onClose: (...args: unknown[]) => { ... }, // on close event handler
},
// component default slot render content
slot,
}
);

```
Expand All @@ -209,24 +211,28 @@ type open = <C extends VNodeTypes>(
component: C,
/** render options */
options?: ProgrammaticOptions<C>,
/** default slot content */
slot?: unknown
) => ProgrammaticExpose;


type ProgrammaticOptions<C extends VNodeTypes> = {
/**
* The target specifies the element the component get rendered into - default is `document.body`.
* The target specifies the element the component get rendered into.
* @default `document.body`.
*/
target?: string | HTMLElement;
/**
* Specify the template `id` for the programmatic container element.
* @default `programmatic-app`
*/
appId?: string;
/**
* 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.
*/
props: ComponentProps<C>,
props?: ComponentProps<C>,
/**
* On component close event.
* This get called when the component emits `close` or the exposed `close` function get called.
Expand Down
31 changes: 8 additions & 23 deletions packages/oruga/src/components/loading/useLoadingProgrammatic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,8 @@ declare module "../../index" {
const instances = new InstanceRegistry<ComponentInternalInstance>();

/** useLoadingProgrammatic composable options */
export type LoadingProgrammaticOptions = Readonly<
Omit<LoadingProps, "label">
> & {
label?: string | Array<unknown>;
} & ProgrammaticComponentOptions;
export type LoadingProgrammaticOptions = Readonly<LoadingProps> &
ProgrammaticComponentOptions;

const LoadingProgrammatic = {
/**
Expand All @@ -40,31 +37,19 @@ const LoadingProgrammatic = {
const _options: LoadingProgrammaticOptions =
typeof options === "string" ? { label: options } : options;

let slot;
// render content as slot when is an array
if (Array.isArray(_options.label)) {
slot = _options.label;
delete _options.label;
}

const componentProps: LoadingProps = {
active: true, // set the active default state to true
fullPage: false, // set the full page default state to false
...(_options as LoadingProps),
};

// create programmatic component
return ComponentProgrammatic.open(
Loading,
{
instances, // custom programmatic instance registry
target, // target the component get rendered into
props: componentProps, // component specific props
onClose: _options.onClose, // on close event handler
},
// component default slot render
slot,
);
return ComponentProgrammatic.open(Loading, {
instances, // custom programmatic instance registry
target, // target the component get rendered into
props: componentProps, // component specific props
onClose: _options.onClose, // on close event handler
});
},
/** Close the last registred instance in the loading programmatic instance registry. */
close(...args: unknown[]): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import ModalForm from "./_modal-form.vue";
const oruga = useOruga();
function imageModal(): void {
// here we use a render function to create an dynamic inline component (https://vuejs.org/guide/extras/render-function)
const vnode = h("p", { style: { "text-align": "center" } }, [
h("img", {
src: "https://avatars2.githubusercontent.com/u/66300512?s=200&v=4",
}),
]);
oruga.modal.open({
content: [vnode],
component: vnode,
});
}
Expand Down
37 changes: 10 additions & 27 deletions packages/oruga/src/components/modal/useModalProgrammatic.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
type Component,
type ComponentInternalInstance,
type VNodeTypes,
} from "vue";
import { type Component, type ComponentInternalInstance } from "vue";
import {
InstanceRegistry,
ComponentProgrammatic,
Expand All @@ -25,10 +21,9 @@ const instances = new InstanceRegistry<ComponentInternalInstance>();

/** useModalProgrammatic composable options */
export type ModalProgrammaticOptions<C extends Component> = Readonly<
Omit<ModalProps<C>, "content">
> & {
content?: string | Array<unknown>;
} & ProgrammaticComponentOptions;
ModalProps<C>
> &
ProgrammaticComponentOptions;

const ModalProgrammatic = {
/**
Expand All @@ -44,30 +39,18 @@ const ModalProgrammatic = {
const _options: ModalProgrammaticOptions<C> =
typeof options === "string" ? { content: options } : options;

let slot;
// render content as slot when is an array
if (Array.isArray(_options.content)) {
slot = _options.content;
delete _options.content;
}

const componentProps: ModalProps<C> = {
active: true, // set the active default state to true
...(_options as ModalProps<C>),
};

// create programmatic component
return ComponentProgrammatic.open(
Modal as VNodeTypes,
{
instances, // custom programmatic instance registry
target, // target the component get rendered into
props: componentProps, // component specific props
onClose: _options.onClose, // on close event handler
},
// component default slot to render content
slot,
);
return ComponentProgrammatic.open(Modal, {
instances, // custom programmatic instance registry
target, // target the component get rendered into
props: componentProps, // component specific props
onClose: _options.onClose, // on close event handler
});
},
/** Close the last registred instance in the modal programmatic instance registry. */
close(...args: unknown[]): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,23 @@ onBeforeMount(() => {
if (parentTop.value && parentBottom.value) return;
// create notices top container if not alread there
if (!parentTop.value) {
parentTop.value = document.createElement("div");
parentTop.value.className = `${rootClasses.join(
" ",
)} ${topClasses.join(" ")}`;
}
// create notices bottom container if not alread there
if (!parentBottom.value) {
parentBottom.value = document.createElement("div");
parentBottom.value.className = `${rootClasses.join(
" ",
)} ${bottomClasses.join(" ")}`;
}
// append notices top and bottom container to given container
props.container.appendChild(parentTop.value);
props.container.appendChild(parentBottom.value);
Expand Down Expand Up @@ -133,6 +136,7 @@ const shouldQueue = computed(() =>
: false,
);
/** move the rendered component template into the correct parent container */
function showNotice(): void {
if (!correctParent.value) return;
Expand Down
4 changes: 2 additions & 2 deletions packages/oruga/src/components/notification/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { ComponentProps } from "vue-component-type-helpers";
export type NotificationProps = {
/** Override existing theme classes completely */
override?: boolean;
/** Message text (can contain HTML), unnecessary when default slot is used */
message?: string | string[];
/** Message text, unnecessary when default slot is used */
message?: string;
/** Whether modal is active or not, use v-model:active to make it two-way binding */
active?: boolean;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,9 @@ const instances = new InstanceRegistry<ComponentInternalInstance>();

/** useNotificationProgrammatic composable options */
export type NotificationProgrammaticOptions<C extends Component> = Readonly<
Omit<
NotificationNoticeProps<C> & NotificationProps,
"message" | "container"
>
> & {
message?: string | Array<unknown>;
} & ProgrammaticComponentOptions;
Omit<NotificationNoticeProps<C> & NotificationProps, "container">
> &
ProgrammaticComponentOptions;

const NotificationProgrammatic = {
/** Returns the number of registered active instances. */
Expand All @@ -46,31 +42,19 @@ const NotificationProgrammatic = {
const _options: NotificationProgrammaticOptions<C> =
typeof options === "string" ? { message: options } : options;

let slot;
// render message as slot when is an array
if (Array.isArray(_options.message)) {
slot = _options.message;
delete _options.message;
}

const componentProps: NotificationNoticeProps<C> = {
position: getOption("notification.position", "top-right"),
container: document.body,
..._options, // pass all props to the internal notification component
};

// create programmatic component
return ComponentProgrammatic.open(
NotificationNotice,
{
instances, // custom programmatic instance registry
target, // target the component get rendered into
props: componentProps, // component specific props
onClose: _options.onClose, // on close event handler
},
// component default slot render
slot,
);
return ComponentProgrammatic.open(NotificationNotice, {
instances, // custom programmatic instance registry
target, // target the component get rendered into
props: componentProps, // component specific props
onClose: _options.onClose, // on close event handler
});
},
/** Close the last registred instance in the notification programmatic instance registry. */
close(...args: unknown[]): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const ProgrammaticComponent = defineComponent<
);
},
{
name: "ProgrammaticApp",
// manual runtime props declaration is currently still needed.
props: ["component", "props", "instances"],
// manual runtime emits declaration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,54 +152,59 @@ describe("useProgrammatic tests", () => {
});

test("test closeAll is working correctly", async () => {
const root = document.createElement("div");

// open elements
ComponentProgrammatic.open("div");
ComponentProgrammatic.open("div");
ComponentProgrammatic.open("div", { target: root });
ComponentProgrammatic.open("div", { target: root });

let bodyElements = document.body.querySelectorAll("*");
expect(bodyElements).toHaveLength(2);
let apps = root.querySelectorAll("#programmatic-app");
expect(apps).toHaveLength(2);

// close all elements
ComponentProgrammatic.closeAll();
vi.runAllTimers();

bodyElements = document.body.querySelectorAll("*");
expect(bodyElements).toHaveLength(0);
// check elements are removed
apps = root.querySelectorAll("#programmatic-app");
expect(apps).toHaveLength(0);
});

test("test close last is working correctly", async () => {
// open elements
ComponentProgrammatic.open("div");
ComponentProgrammatic.open("div");

let bodyElements = document.body.querySelectorAll("*");
let bodyElements = document.body.querySelectorAll("#programmatic-app");
expect(bodyElements).toHaveLength(2);

// close last element
ComponentProgrammatic.close();
vi.runAllTimers();

bodyElements = document.body.querySelectorAll("*");
bodyElements = document.body.querySelectorAll("#programmatic-app");
expect(bodyElements).toHaveLength(1);

// close last element
ComponentProgrammatic.close();
vi.runAllTimers();

bodyElements = document.body.querySelectorAll("*");
bodyElements = document.body.querySelectorAll("#programmatic-app");
expect(bodyElements).toHaveLength(0);
});

test("test render slot correctly", async () => {
const component = createVNode({
template: `<button id="mycomp"><slot /></button>`,
});

// create inner slot element
const slot = h("p", { "data-oruga": "inner-slot" }, "HELP");

const component = createVNode(
{ template: `<button id="mycomp"><slot /></button>` },
null,
() => slot,
);

// open elements
const { close } = ComponentProgrammatic.open(component, {}, slot);
const { close } = ComponentProgrammatic.open(component);

// check element exist
const button = document.body.querySelector("button");
Expand Down
Loading

0 comments on commit 40dd968

Please sign in to comment.