diff --git a/packages/docs/components/Notification.md b/packages/docs/components/Notification.md index cf6a4a10a..c1b5f422e 100644 --- a/packages/docs/components/Notification.md +++ b/packages/docs/components/Notification.md @@ -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` |
From config:
notification: {
  iconPack: undefined
}
| | iconSize | Icon size | string | `small`, `medium`, `large` |
From config:
notification: {
  iconSize: "large"
}
| -| 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` |
From config:
notification: {
  position: "top"
}
| | type | Type (color) of the notification | string | `info`, `success`, `warning`, `danger` | | diff --git a/packages/docs/documentation/composables.md b/packages/docs/documentation/composables.md index 68250e657..885a3acac 100644 --- a/packages/docs/documentation/composables.md +++ b/packages/docs/documentation/composables.md @@ -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"; @@ -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, + } ); ``` @@ -209,16 +211,20 @@ type open = ( component: C, /** render options */ options?: ProgrammaticOptions, - /** default slot content */ - slot?: unknown ) => ProgrammaticExpose; type ProgrammaticOptions = { /** - * 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. @@ -226,7 +232,7 @@ type ProgrammaticOptions = { * `class` and `style` have the same object / array value support like in templates. * Event listeners should be passed as onXxx. */ - props: ComponentProps, + props?: ComponentProps, /** * On component close event. * This get called when the component emits `close` or the exposed `close` function get called. diff --git a/packages/oruga/src/components/loading/useLoadingProgrammatic.ts b/packages/oruga/src/components/loading/useLoadingProgrammatic.ts index 6d4006005..fe14209e2 100644 --- a/packages/oruga/src/components/loading/useLoadingProgrammatic.ts +++ b/packages/oruga/src/components/loading/useLoadingProgrammatic.ts @@ -20,11 +20,8 @@ declare module "../../index" { const instances = new InstanceRegistry(); /** useLoadingProgrammatic composable options */ -export type LoadingProgrammaticOptions = Readonly< - Omit -> & { - label?: string | Array; -} & ProgrammaticComponentOptions; +export type LoadingProgrammaticOptions = Readonly & + ProgrammaticComponentOptions; const LoadingProgrammatic = { /** @@ -40,13 +37,6 @@ 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 @@ -54,17 +44,12 @@ const LoadingProgrammatic = { }; // 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 { diff --git a/packages/oruga/src/components/modal/examples/programmatically.vue b/packages/oruga/src/components/modal/examples/programmatically.vue index ad8e32219..0e8ee6983 100644 --- a/packages/oruga/src/components/modal/examples/programmatically.vue +++ b/packages/oruga/src/components/modal/examples/programmatically.vue @@ -6,6 +6,7 @@ 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", @@ -13,7 +14,7 @@ function imageModal(): void { ]); oruga.modal.open({ - content: [vnode], + component: vnode, }); } diff --git a/packages/oruga/src/components/modal/useModalProgrammatic.ts b/packages/oruga/src/components/modal/useModalProgrammatic.ts index 9052b65ac..5092ce9c5 100644 --- a/packages/oruga/src/components/modal/useModalProgrammatic.ts +++ b/packages/oruga/src/components/modal/useModalProgrammatic.ts @@ -1,8 +1,4 @@ -import { - type Component, - type ComponentInternalInstance, - type VNodeTypes, -} from "vue"; +import { type Component, type ComponentInternalInstance } from "vue"; import { InstanceRegistry, ComponentProgrammatic, @@ -25,10 +21,9 @@ const instances = new InstanceRegistry(); /** useModalProgrammatic composable options */ export type ModalProgrammaticOptions = Readonly< - Omit, "content"> -> & { - content?: string | Array; -} & ProgrammaticComponentOptions; + ModalProps +> & + ProgrammaticComponentOptions; const ModalProgrammatic = { /** @@ -44,30 +39,18 @@ const ModalProgrammatic = { const _options: ModalProgrammaticOptions = 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 = { active: true, // set the active default state to true ...(_options as ModalProps), }; // 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 { diff --git a/packages/oruga/src/components/notification/NotificationNotice.vue b/packages/oruga/src/components/notification/NotificationNotice.vue index bcdbf2cbd..a26d978db 100644 --- a/packages/oruga/src/components/notification/NotificationNotice.vue +++ b/packages/oruga/src/components/notification/NotificationNotice.vue @@ -75,6 +75,7 @@ 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( @@ -82,6 +83,7 @@ onBeforeMount(() => { )} ${topClasses.join(" ")}`; } + // create notices bottom container if not alread there if (!parentBottom.value) { parentBottom.value = document.createElement("div"); parentBottom.value.className = `${rootClasses.join( @@ -89,6 +91,7 @@ onBeforeMount(() => { )} ${bottomClasses.join(" ")}`; } + // append notices top and bottom container to given container props.container.appendChild(parentTop.value); props.container.appendChild(parentBottom.value); @@ -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; diff --git a/packages/oruga/src/components/notification/props.ts b/packages/oruga/src/components/notification/props.ts index 8486ccdef..8067e6b7a 100644 --- a/packages/oruga/src/components/notification/props.ts +++ b/packages/oruga/src/components/notification/props.ts @@ -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; /** diff --git a/packages/oruga/src/components/notification/useNotificationProgrammatic.ts b/packages/oruga/src/components/notification/useNotificationProgrammatic.ts index 5ba4753ad..18ef79d6c 100644 --- a/packages/oruga/src/components/notification/useNotificationProgrammatic.ts +++ b/packages/oruga/src/components/notification/useNotificationProgrammatic.ts @@ -22,13 +22,9 @@ const instances = new InstanceRegistry(); /** useNotificationProgrammatic composable options */ export type NotificationProgrammaticOptions = Readonly< - Omit< - NotificationNoticeProps & NotificationProps, - "message" | "container" - > -> & { - message?: string | Array; -} & ProgrammaticComponentOptions; + Omit & NotificationProps, "container"> +> & + ProgrammaticComponentOptions; const NotificationProgrammatic = { /** Returns the number of registered active instances. */ @@ -46,13 +42,6 @@ const NotificationProgrammatic = { const _options: NotificationProgrammaticOptions = 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 = { position: getOption("notification.position", "top-right"), container: document.body, @@ -60,17 +49,12 @@ const NotificationProgrammatic = { }; // 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 { diff --git a/packages/oruga/src/components/programmatic/ProgrammaticComponent.ts b/packages/oruga/src/components/programmatic/ProgrammaticComponent.ts index db48a8109..bbabea1fa 100644 --- a/packages/oruga/src/components/programmatic/ProgrammaticComponent.ts +++ b/packages/oruga/src/components/programmatic/ProgrammaticComponent.ts @@ -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 diff --git a/packages/oruga/src/components/programmatic/tests/useProgrammatic.test.ts b/packages/oruga/src/components/programmatic/tests/useProgrammatic.test.ts index 85d546cd0..c144f8d4e 100644 --- a/packages/oruga/src/components/programmatic/tests/useProgrammatic.test.ts +++ b/packages/oruga/src/components/programmatic/tests/useProgrammatic.test.ts @@ -152,19 +152,22 @@ 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 () => { @@ -172,34 +175,36 @@ describe("useProgrammatic tests", () => { 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: ``, - }); - // create inner slot element const slot = h("p", { "data-oruga": "inner-slot" }, "HELP"); + const component = createVNode( + { template: `` }, + null, + () => slot, + ); + // open elements - const { close } = ComponentProgrammatic.open(component, {}, slot); + const { close } = ComponentProgrammatic.open(component); // check element exist const button = document.body.querySelector("button"); diff --git a/packages/oruga/src/components/programmatic/useProgrammatic.ts b/packages/oruga/src/components/programmatic/useProgrammatic.ts index 647119c9f..683243ab3 100644 --- a/packages/oruga/src/components/programmatic/useProgrammatic.ts +++ b/packages/oruga/src/components/programmatic/useProgrammatic.ts @@ -1,9 +1,8 @@ import { - createVNode, - render, + createApp, + type App, type ComponentInternalInstance, type EmitsToProps, - type VNode, type VNodeTypes, } from "vue"; @@ -30,10 +29,15 @@ const instances = new InstanceRegistry(); /** useProgrammatic composable `open` function options */ export type ProgrammaticOptions = { /** - * Specify a target the component get rendered into + * Specify a target the component get rendered into. * @default `document.body` */ target?: string | HTMLElement | null; + /** + * Specify the template `id` for the programmatic container element. + * @default `programmatic-app` + */ + appId?: string; } & Omit, "component"> & // component props EmitsToProps>; // component emit props @@ -54,12 +58,10 @@ export const ComponentProgrammatic = { * Create a new programmatic component instance. * @param component component to render * @param options render options - * @param slot default slot content - see {@link https://vuejs.org/api/render-function.html#render-function-apis |Vue render function} */ open( component: C, options?: ProgrammaticOptions, - slot?: unknown, ): ProgrammaticExpose { options = { instances, ...options }; @@ -72,39 +74,45 @@ export const ComponentProgrammatic = { ? options.target : document.body; - // cache container - let container: HTMLDivElement | null = document.createElement("div"); + // create app container + let container: HTMLDivElement | undefined = + document.createElement("div"); + container.id = options.appId || "programmatic-app"; + + // place the app container into the target element + target.appendChild(container); - // clear vnode + // clear instance handler function onDestroy(): void { - // clear the container and all connected child node by rendering null into it - if (container) render(null, container); - container = null; // reset the variable - vnode = null; // reset the vnode + // destroy app/component + if (app) { + app.unmount(); + app = undefined; + } + // clear container + if (container) { + target.removeChild(container); + container = undefined; + } } - // create dynamic component - let vnode: VNode | null = createVNode( - ProgrammaticComponent, - { - instances: options.instances, // programmatic registry instance - can be overriden by given in options - component, // the component which should be rendered - props: { ...options.props, container: target }, // component props including the target as `container` - onClose: options.onClose, // custom onClose handler - onDestroy, // node destory cleanup handler - } as ProgrammaticComponentProps, - slot ? (): unknown => slot : null, // default slot render function - ); - if (VueInstance?._context) vnode.appContext = VueInstance._context; // set app context - - // render a new vue instance into the cache container - render(vnode, container); - - // place rendered elements into target element - target.append(...container.children); - - // return exposed functionalities - return vnode.component?.exposed as ProgrammaticExpose; + // create a new vue app instance with the ProgrammaticComponent as root + let app: App | undefined = createApp(ProgrammaticComponent, { + instances: options.instances, // programmatic registry instance - can be overriden by given in options + component, // the component which should be rendered + props: { ...options.props, container: target }, // component props including the target as `container` + onClose: options.onClose, // custom onClose handler + onDestroy, // node destory cleanup handler + }); + + // share the current context to the new app instance if running inside a nother app + if (VueInstance) app._context = VueInstance._context; + + // render the new vue instance into the container + app.mount(container); + + // return exposed programmatic functionalities + return app?._instance?.exposed as ProgrammaticExpose; }, /** close the last registred instance in the global programmatic instance registry */ close(...args: unknown[]): void { diff --git a/packages/oruga/src/components/sidebar/useSidebarProgrammatic.ts b/packages/oruga/src/components/sidebar/useSidebarProgrammatic.ts index 0bf14ae76..c7159c521 100644 --- a/packages/oruga/src/components/sidebar/useSidebarProgrammatic.ts +++ b/packages/oruga/src/components/sidebar/useSidebarProgrammatic.ts @@ -1,8 +1,4 @@ -import { - type Component, - type ComponentInternalInstance, - type VNodeTypes, -} from "vue"; +import { type Component, type ComponentInternalInstance } from "vue"; import { InstanceRegistry, ComponentProgrammatic, @@ -48,7 +44,7 @@ const SidebarProgrammatic = { }; // create programmatic component - return ComponentProgrammatic.open(Sidebar as VNodeTypes, { + return ComponentProgrammatic.open(Sidebar, { instances, // custom programmatic instance registry target, // target the component get rendered into props: componentProps, // component specific props