Files
Brandy Carney ab4f2791c1 fix(vue): pass router-link value to href to properly render clickable elements (#29745)
Issue number: N/A

---------

## What is the current behavior?
Ionic Framework Vue components using `router-link` do not apply an
`href` property which causes components to render `div` or `button`
elements when they should render an `a`. This is inconsistent with the
way Angular and Vue handle router link.

## What is the new behavior?
Updates `@stencil/vue-output-target` to latest which adds the code from
the following PR:
https://github.com/ionic-team/stencil-ds-output-targets/pull/446

The update in vue output target checks if `router-link` and `navManager`
are defined so this fix only applies to Ionic Framework components. If
both are defined then it adds the `href` property to the element with
the value of `router-link`.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

## Other information

Dev build: `8.2.7-dev.11722629362.1ac136c4`
2024-08-05 21:35:26 +00:00

237 lines
8.1 KiB
TypeScript

// @ts-nocheck
// It's easier and safer for Volar to disable typechecking and let the return type inference do its job.
import { defineComponent, getCurrentInstance, h, inject, ref, Ref, withDirectives } from 'vue';
export interface InputProps<T> {
modelValue?: T;
}
const UPDATE_VALUE_EVENT = 'update:modelValue';
const MODEL_VALUE = 'modelValue';
const ROUTER_LINK_VALUE = 'routerLink';
const NAV_MANAGER = 'navManager';
const ROUTER_PROP_PREFIX = 'router';
const ARIA_PROP_PREFIX = 'aria';
/**
* Starting in Vue 3.1.0, all properties are
* added as keys to the props object, even if
* they are not being used. In order to correctly
* account for both value props and v-model props,
* we need to check if the key exists for Vue <3.1.0
* and then check if it is not undefined for Vue >= 3.1.0.
* See https://github.com/vuejs/vue-next/issues/3889
*/
const EMPTY_PROP = Symbol();
const DEFAULT_EMPTY_PROP = { default: EMPTY_PROP };
interface NavManager<T = any> {
navigate: (options: T) => void;
}
const getComponentClasses = (classes: unknown) => {
return (classes as string)?.split(' ') || [];
};
const getElementClasses = (
ref: Ref<HTMLElement | undefined>,
componentClasses: Set<string>,
defaultClasses: string[] = []
) => {
return [...Array.from(ref.value?.classList || []), ...defaultClasses].filter(
(c: string, i, self) => !componentClasses.has(c) && self.indexOf(c) === i
);
};
/**
* Create a callback to define a Vue component wrapper around a Web Component.
*
* @prop name - The component tag name (i.e. `ion-button`)
* @prop componentProps - An array of properties on the
* component. These usually match up with the @Prop definitions
* in each component's TSX file.
* @prop customElement - An option custom element instance to pass
* to customElements.define. Only set if `includeImportCustomElements: true` in your config.
* @prop modelProp - The prop that v-model binds to (i.e. value)
* @prop modelUpdateEvent - The event that is fired from your Web Component when the value changes (i.e. ionChange)
*/
export const defineContainer = <Props, VModelType = string | number | boolean>(
name: string,
defineCustomElement: any,
componentProps: string[] = [],
modelProp?: string,
modelUpdateEvent?: string
) => {
/**
* Create a Vue component wrapper around a Web Component.
* Note: The `props` here are not all properties on a component.
* They refer to whatever properties are set on an instance of a component.
*/
if (defineCustomElement !== undefined) {
defineCustomElement();
}
const Container = defineComponent<Props & InputProps<VModelType>>((props, { attrs, slots, emit }) => {
let modelPropValue = props[modelProp];
const containerRef = ref<HTMLElement>();
const classes = new Set(getComponentClasses(attrs.class));
/**
* This directive is responsible for updating any reactive
* reference associated with v-model on the component.
* This code must be run inside of the "created" callback.
* Since the following listener callbacks as well as any potential
* event callback defined in the developer's app are set on
* the same element, we need to make sure the following callbacks
* are set first so they fire first. If the developer's callback fires first
* then the reactive reference will not have been updated yet.
*/
const vModelDirective = {
created: (el: HTMLElement) => {
const eventsNames = Array.isArray(modelUpdateEvent) ? modelUpdateEvent : [modelUpdateEvent];
eventsNames.forEach((eventName: string) => {
el.addEventListener(eventName.toLowerCase(), (e: Event) => {
/**
* Only update the v-model binding if the event's target is the element we are
* listening on. For example, Component A could emit ionChange, but it could also
* have a descendant Component B that also emits ionChange. We only want to update
* the v-model for Component A when ionChange originates from that element and not
* when ionChange bubbles up from Component B.
*/
if (e.target.tagName === el.tagName) {
modelPropValue = (e?.target as any)[modelProp];
emit(UPDATE_VALUE_EVENT, modelPropValue);
}
});
});
},
};
const currentInstance = getCurrentInstance();
const hasRouter = currentInstance?.appContext?.provides[NAV_MANAGER];
const navManager: NavManager | undefined = hasRouter ? inject(NAV_MANAGER) : undefined;
const handleRouterLink = (ev: Event) => {
const { routerLink } = props;
if (routerLink === EMPTY_PROP) return;
if (navManager !== undefined) {
/**
* This prevents the browser from
* performing a page reload when pressing
* an Ionic component with routerLink.
* The page reload interferes with routing
* and causes ion-back-button to disappear
* since the local history is wiped on reload.
*/
ev.preventDefault();
let navigationPayload: any = { event: ev };
for (const key in props) {
const value = props[key];
if (props.hasOwnProperty(key) && key.startsWith(ROUTER_PROP_PREFIX) && value !== EMPTY_PROP) {
navigationPayload[key] = value;
}
}
navManager.navigate(navigationPayload);
} else {
console.warn('Tried to navigate, but no router was found. Make sure you have mounted Vue Router.');
}
};
return () => {
modelPropValue = props[modelProp];
getComponentClasses(attrs.class).forEach((value) => {
classes.add(value);
});
const oldClick = props.onClick;
const handleClick = (ev: Event) => {
if (oldClick !== undefined) {
oldClick(ev);
}
if (!ev.defaultPrevented) {
handleRouterLink(ev);
}
};
let propsToAdd: any = {
ref: containerRef,
class: getElementClasses(containerRef, classes),
onClick: handleClick,
};
/**
* We can use Object.entries here
* to avoid the hasOwnProperty check,
* but that would require 2 iterations
* where as this only requires 1.
*/
for (const key in props) {
const value = props[key];
if ((props.hasOwnProperty(key) && value !== EMPTY_PROP) || key.startsWith(ARIA_PROP_PREFIX)) {
propsToAdd[key] = value;
}
}
if (modelProp) {
/**
* If form value property was set using v-model
* then we should use that value.
* Otherwise, check to see if form value property
* was set as a static value (i.e. no v-model).
*/
if (props[MODEL_VALUE] !== EMPTY_PROP) {
propsToAdd = {
...propsToAdd,
[modelProp]: props[MODEL_VALUE],
};
} else if (modelPropValue !== EMPTY_PROP) {
propsToAdd = {
...propsToAdd,
[modelProp]: modelPropValue,
};
}
}
// If router link is defined, add href to props
// in order to properly render an anchor tag inside
// of components that should become activatable and
// focusable with router link.
if (props[ROUTER_LINK_VALUE] !== EMPTY_PROP) {
propsToAdd = {
...propsToAdd,
href: props[ROUTER_LINK_VALUE],
};
}
/**
* vModelDirective is only needed on components that support v-model.
* As a result, we conditionally call withDirectives with v-model components.
*/
const node = h(name, propsToAdd, slots.default && slots.default());
return modelProp === undefined ? node : withDirectives(node, [[vModelDirective]]);
};
});
if (typeof Container !== 'function') {
Container.name = name;
Container.props = {
[ROUTER_LINK_VALUE]: DEFAULT_EMPTY_PROP,
};
componentProps.forEach((componentProp) => {
Container.props[componentProp] = DEFAULT_EMPTY_PROP;
});
if (modelProp) {
Container.props[MODEL_VALUE] = DEFAULT_EMPTY_PROP;
Container.emits = [UPDATE_VALUE_EVENT];
}
}
return Container;
};