I want to implement the autocomplete search (the one on the left) from this codepen to pure web components. But something went wrong because slots don't work and something else also doesn't work but I can't figure out what it is. What I have so far
Search-select
const template = `
<p>
<slot name="autocomp" results="${this.results}"
searchList="${(event) => this.setQuery(event)}"
>
fgfgfg
</slot>
yo
</p>
`;
class SearchSelect extends HTMLElement {
constructor() {
super();
this.query = "";
this.results = [];
this.options = [
"Inside Out",
"John Wick",
"Jurassic World",
"The Lord of the Rings",
"Pacific Rim",
"Pirates of the Caribbean",
"Planet of the Apes",
"Saw",
"Sicario",
"Zombies",
];
this.shadow = this.attachShadow({ mode: "open" });
}
setQuery(event) {
console.log(event.target);
this.query = event.target.value;
}
get options() {
return this.getAttribute("options");
}
set options(val) {
this.setAttribute("options", val);
}
static get observedAttributes() {
return ["options", "filterMethod"];
}
filterMethod(options, query) {
return options.filter((option) =>
option.toLowerCase().includes(query.toLowerCase())
);
}
attributeChangedCallback(prop, oldValue, newValue) {
if (prop === "options") {
this.results = this.filterMethod(this.options, this.query);
this.render();
}
if (prop === "filterMethod") {
this.results = this.filterMethod(this.options, this.query);
this.render();
}
}
render() {
this.shadow.innerHTML = template;
}
connectedCallback() {
this.render();
}
}
customElements.define("search-select", SearchSelect);
Autocomplete
const templ = `
<search-select>
<div class="autocomplete">
<input
type="text"
placeholder="Type to search list"
onchange="${this.searchList}"
onfocus="${this.showDropdown}"
onblur="${this.hideDropdown}"
/>
<div class="autocomplete-dropdown" v-if="dropdownVisible">
<ul class="autocomplete-search-results-list">
${this.result}
</ul>
</div>
</div>
</search-select>
`;
class Autocomplete extends HTMLElement {
constructor() {
super();
this.dropdownVisible = false;
this.rslts = "";
this.shadow = this.attachShadow({ mode: "open" });
}
get results() {
return this.getAttribute("results");
}
set results(val) {
this.setAttribute("results", val);
}
get searchList() {
return this.getAttribute("searchList");
}
showDropdown() {
this.dropdownVisible = true;
}
hideDropdown() {
this.dropdownVisible = false;
}
attributeChangedCallback(prop, oldValue, newValue) {
this.render();
}
render() {
this.shadow.innerHTML = templ;
}
connectedCallback() {
this.render();
}
}
customElements.define("auto-complete", Autocomplete);
Your current approach is completely wrong. Vue is reactive framework. Web components do not provide reactivity out of box.
The translation of Vue2 component to direct Web component is not straight forward. The slots do not work because Vue.js slots are not the same as Web component slots. They are just conceptually modeled after them.
First, when you use the Vue.js slot, you are practically putting some part of the vDOM (produced as a result of JSX) defined by the calling component into the Search or Autocomplete component. It is not a real DOM. Web components, on the other hand, provide slot which actually accepts a real DOM (light DOM).
Next, your render method is practically useless. You are simply doing this.shadow.innerHTML = template; which will simply append the string as HTML into the real DOM. You are not resolving the template nodes. Vue.js provides a reactivity out of box (that's why you need Vue/React). Web components do not provide such reactivity. On each render, you are re-creating entire DOM which is not a good way to do it. When you are not using any framework to build web component, you should construct all the required DOM in connectedCallback and then keep on selectively updating using DOM manipulation API. This is imperative approach to building UIs.
Third, you are using named slot while consuming it in auto complete, you are not specifying the named slot. So whatever is the HTML you see is not getting attached to the Shadow DOM.
You will need to
Building a complex component like Auto Complete needs a basic reactivity system in place that takes care of efficiently and automatically updating the DOM. If you do not need full framework, consider using Stencil, LitElement, etc. If you can use Vue.js, just use it and wrap it into Web component using helper function.
For Vue 2, you can use the wrapper helper library. For Vue 3, you can use the built-in helper.
Related
I've got form with dynamic number of input fields and i need to transliterate data, passed to this fields in 'live'. I wrote custom directive which do all job, but there is an a error -> it converts all chars except last one (should be привет->privet, while привет->priveт). This is my source code
directives: {
transliterate: {
update(element, binding) {
element.value = tr(element.value)
}
}
}
This is PUG (Jade)
input(v-model='requestHotels.travellers[index].first_name', v-transliterate='true')
tr - just function, which transliterate from ru to en
I knew why this happening, but i can't solve it by myself. Any ideas?
1) Consider using computed property instead of directive. Personally, I don't like directives because they can add alot of useless complexity to your code. But there are some complex cases where they can be really useful. But this one is not one of them.
export default {
data: () => ({
tranliteratedValue: ""
}),
computed: {
vModelValue: {
get() {
return this.tranliteratedValue;
},
set(value) {
this.tranliteratedValue = transl.transform(value);
}
}
}
};
Full example: https://codesandbox.io/s/039vvo13yv?module=%2Fsrc%2Fcomponents%2FComputedProperty.vue
2) You can use filter and transliterate during render
filters: {
transliterate(value) {
return transl.transform(value);
}
}
Then in your template:
<p>{{ value | transliterate }}</p>
Full example: https://codesandbox.io/s/039vvo13yv?module=%2Fsrc%2Fcomponents%2FFilter.vue
3) Transparent wrapper technique (using custom component)
The idea behind transparent wrapper is that you should create custom component that behave as build-in input (and accepts the same arguments) but you can intercept events and change behaviour as you'd like. In your example - tranliterate input text.
<textarea
v-bind="$attrs"
:value="value"
v-on="listeners"
/>
computed: {
listeners() {
return {
...this.$listeners,
input: event => {
const value = transl.transform(event.target.value + "");
this.$emit("input", value);
}
};
}
}
Full example: https://codesandbox.io/s/039vvo13yv?module=%2Fsrc%2Fcomponents%2Finc%2FTransliteratedInput.vue
Read more about Transparent wrapper technique here https://github.com/chrisvfritz/7-secret-patterns/blob/master/slides-2018-03-03-spotlight-export.pdf
You can check all 3 working approaches here https://codesandbox.io/s/039vvo13yv
How to achieve 2 way data binding for input in dojo 2?
handleChange = (e) => {
this.setState({ textValue: e.target.value });}
<Input name='title' defaultValue={this.state.textValue} placeholder='title...' onChange={this.handleChange} />
I know this is how we do in React but don't know how to achieve in dojo 2.
In fact React supports only one-way binding, and your example illustrates it well. You need to update state, to re-render react component.
And as far as I understood from dojo2 docs and tutorials, there is almost same approach under the hood. Take a look here
Dojo 2 is built around unidirectional, top-down property propagation where it is the parent widget’s job to pass properties down to its children. In fact, a child widget has no direct reference to a parent widget! When a property changes, widgets are re-rendered (using an efficient virtual DOM) to reflect the updated state.
And it may look like this:
private _addWorker() {
this._workerData = this._workerData.concat(this._newWorker);
this._newWorker = {};
this.invalidate();
}
You change data and call invalidate() to re-render widget.
This is the solution to achieve 2 way data binding in Dojo 2.
InputWidget:-
interface IInputProps {
value: string;
onChange(event: Event): void;
}
export class InputWidget extends WidgetBase<IInputProps> {
private _onChange (event: Event) {
event.stopPropagation();
this.properties.onChange && this.properties.onChange((event.target as HTMLInputElement).value);
}
protected render(): DNode {
const { value } = this.properties;
return v('input', {
key: "input",
type: "text",
value
onchange: this._onChange
});
}
}
InputWrapper widget:-
export class InputWrapper extends WidgetBase<IWrapperProps> {
private inputValue: string = '';
protected inputValueChanges(value: string) {
this.inputValue = value;
this.invalidate();
}
protected render(): DNode {
<div>
{w(InputWidget, {onchange: this.inputValueChanges, value: this.inputValue })}
<span>Input Value:- {this.inputValue}</span>
</div>
}
}
This is the solution to achieve 2 way data binding in Dojo 2.
Hope this will be helpful! :(
I need to find a reliable solution to making the two frameworks play nicely.
Using materialize-css, their select element uses jquery to apply the value change. However that then does not trigger aurelia in seeing the change. Using the technique of...
$("select")
.change((eventObject: JQueryEventObject) => {
fireEvent(eventObject.target, "change");
});
I can fire an event aurelia sees, however, aurelia then cause the event to be triggered again while it's updating it's bindings and I end up in an infinite loop.... Stack Overflow :D
Whats the most reliable way of getting the two to play together in this respect?
I have worked with materialize-css + aurelia for a while and I can confirm that the select element from materialize is quite problematic.
I just wanted to share one of my solutions here in case anyone wants some additional examples. Ashley's is probably cleaner in this case. Mine uses a bindable for the options instead of a slot.
Other than that the basic idea is the same (using a guard variable and a micro task).
One lesson I learned in dealing with 3rd party plugins and two-way data binding is that it helps to make a more clear, distinct separation between handling changes that originate from the binding target (the select element on the DOM) and changes that originate from the binding source (e.g. the ViewModel of the page containing the element).
I tend to use change handlers with names like onValueChangedByBindingSource and onValueChangedByBindingTarget to deal with the different ways of syncing the ViewModel with the DOM in a way that results in less confusing code.
Example: https://gist.run?id=6ee17e333cd89dc17ac62355a4b31ea9
src/material-select.html
<template>
<div class="input-field">
<select value.two-way="value" id="material-select">
<option repeat.for="option of options" model.bind="option">
${option.displayName}
</option>
</select>
</div>
</template>
src/material-select.ts
import {
customElement,
bindable,
bindingMode,
TaskQueue,
Disposable,
BindingEngine,
inject,
DOM
} from "aurelia-framework";
#customElement("material-select")
#inject(DOM.Element, TaskQueue, BindingEngine)
export class MaterialSelect {
public element: HTMLElement;
public selectElement: HTMLSelectElement;
#bindable({ defaultBindingMode: bindingMode.twoWay })
public value: { name: string, value: number };
#bindable({ defaultBindingMode: bindingMode.oneWay })
public options: { displayName: string }[];
constructor(
element: Element,
private tq: TaskQueue,
private bindingEngine: BindingEngine
) {
this.element = element;
}
private subscription: Disposable;
public isAttached: boolean = false;
public attached(): void {
this.selectElement = <HTMLSelectElement>this.element.querySelector("select");
this.isAttached = true;
$(this.selectElement).material_select();
$(this.selectElement).on("change", this.handleChangeFromNativeSelect);
this.subscription = this.bindingEngine.collectionObserver(this.options).subscribe(() => {
$(this.selectElement).material_select();
});
}
public detached(): void {
this.isAttached = false;
$(this.selectElement).off("change", this.handleChangeFromNativeSelect);
$(this.selectElement).material_select("destroy");
this.subscription.dispose();
}
private valueChanged(newValue, oldValue): void {
this.tq.queueMicroTask(() => {
this.handleChangeFromViewModel(newValue);
});
}
private _suspendUpdate = false;
private handleChangeFromNativeSelect = () => {
if (!this._suspendUpdate) {
this._suspendUpdate = true;
let event = new CustomEvent("change", {
bubbles: true
});
this.selectElement.dispatchEvent(event)
this._suspendUpdate = false;
}
}
private handleChangeFromViewModel = (newValue) => {
if (!this._suspendUpdate) {
$(this.selectElement).material_select();
}
}
}
EDIT
How about a custom attribute?
Gist: https://gist.run?id=b895966489502cc4927570c0beed3123
src/app.html
<template>
<div class="container">
<div class="row"></div>
<div class="row">
<div class="col s12">
<div class="input-element" style="position: relative;">
<select md-select value.two-way="currentOption">
<option repeat.for="option of options" model.bind="option">${option.displayName}</option>
</select>
<label>Selected: ${currentOption.displayName}</label>
</div>
</div>
</div>
</div>
</template>
src/app.ts
export class App {
public value: string;
public options: {displayName: string}[];
constructor() {
this.options = new Array<any>();
this.options.push({ displayName: "Option 1" });
this.options.push({ displayName: "Option 2" });
this.options.push({ displayName: "Option 3" });
this.options.push({ displayName: "Option 4" });
}
public attached(): void {
this.value = this.options[1];
}
}
src/md-select.ts
import {
customAttribute,
bindable,
bindingMode,
TaskQueue,
Disposable,
BindingEngine,
DOM,
inject
} from "aurelia-framework";
#inject(DOM.Element, TaskQueue, BindingEngine)
#customAttribute("md-select")
export class MdSelect {
public selectElement: HTMLSelectElement;
#bindable({ defaultBindingMode: bindingMode.twoWay })
public value;
constructor(element: Element, private tq: TaskQueue) {
this.selectElement = element;
}
public attached(): void {
$(this.selectElement).material_select();
$(this.selectElement).on("change", this.handleChangeFromNativeSelect);
}
public detached(): void {
$(this.selectElement).off("change", this.handleChangeFromNativeSelect);
$(this.selectElement).material_select("destroy");
}
private valueChanged(newValue, oldValue): void {
this.tq.queueMicroTask(() => {
this.handleChangeFromViewModel(newValue);
});
}
private _suspendUpdate = false;
private handleChangeFromNativeSelect = () => {
if (!this._suspendUpdate) {
this._suspendUpdate = true;
const event = new CustomEvent("change", { bubbles: true });
this.selectElement.dispatchEvent(event)
this.tq.queueMicroTask(() => this._suspendUpdate = false);
}
}
private handleChangeFromViewModel = (newValue) => {
if (!this._suspendUpdate) {
$(this.selectElement).material_select();
}
}
}
Ok, I spent entirely too long getting this one answered the way I wanted, but more on that later. The actual answer to stop the infinite loop is fairly simple, so let's look at it first. You need to have a guard property, and you'll need to use Aurelia's TaskQueue to help unset the guard property.
Your code will look a little something like this:
$(this.selectElement).change(evt => {
if(!this.guard) {
this.guard = true;
const changeEvent = new Event('change');
this.selectElement.dispatchEvent(changeEvent);
this.taskQueue.queueMicroTask(() => this.guard = false);
}
});
Notice that I'm using queueing up a microtask to unset the guard. This makes sure that everything will work the way you want.
Now that we've got that out of the way, let's look at a gist I created here. In this gist I created a custom element to wrap the Materialize select functionality. While creating this, I learned that select elements and content projection via slot elements don't go together. So you'll see in the code that I have to do some coding gymnastics to move the option elements over from a dummy div element into the select element. I'm going to file an issue so we can look in to this and see if this is a bug in the framework or simply a limitation of the browser.
Normally, I would highly recommend creating a custom element to wrap this functionality. Given the code I had to write to shuffle nodes around, I can't say that I highly recommend creating a custom element. I just really recommend it in this case.
But anyways, there you go!
How to check when DOM is compiled and inserted from Aurelia repeat cycle when the model is updated?
I have the following html:
<div clas="parent">
<div class="list-group">
<a repeat.for="$item of treeData">${$item.label}</a>
</div>
</div>
Here I need to know when all <a> tags are listed in the DOM, in order to run jquery scroll plugin on the parent <div> container.
At first load, I do that from the attached() method and all is fine.
When I update the treeData model from a listener, and try to update the jquery scroll plugin, it looks that the DOM is not compiled, so my scroll plugin can not update properly.
If I put timeout with some minimum value like 200ms it works, but I don't think it is a reliable workaround.
So is there a way to solve that?
Thanks!
My View Model:
#customElement('tree-view')
#inject(Element, ViewResources, BindingEngine)
export class TreeView {
#bindable data = [];
#bindable filterFunc = null;
#bindable filter = false;
#bindable selectedItem;
constructor(element, viewResources, bindingEngine) {
this.element = element;
this.viewResources = viewResources;
this.bindingEngine = bindingEngine;
}
bind(bindingContext, overrideContext) {
this.dataPropertySubscription = this.bindingEngine
.propertyObserver(this, 'data')
.subscribe((newItems, oldItems) => {
this.dataCollectionSubscription.dispose();
this._subscribeToDataCollectionChanges();
this.refresh();
});
this.refresh();
if (this.filter === true) {
this.filterChanged(this.filter);
}
if (this.selectedItem) {
this.selectedItemChanged(this.selectedItem);
}
}
attached() {
$(this.element).perfectScrollbar();
}
refresh() {
this.treeData = processData(this.data, this.filterFunc);
this.listItemMap = new WeakMap();
this.treeData.forEach(li => this.listItemMap.set(li.item, li));
this.filterChanged(this.filter);
$(this.element).perfectScrollbar('update');
}
This is only part of the code, but most valuable I think.
I attach the jq plugin in attached function and try to update it in refresh function. In general I have listener that track model in other view, which then update that one without triggering bind method.
An approach would be to use something called window.requestAnimationFrame (https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame).
In your view-model, when you modify your treeData array, try calling
window.requestAnimationFrame(()=>{
$.fn.somePlugin();
});
Haven't tested this out, but based off what you're telling me, this might do what you need.
You could push your code onto the microTaskQueue, which will schedule your function to be executed on the next event loop. For instance:
import { TaskQueue } from 'aurelia-task-queue';
//...
#inject(Element, ViewResources, BindingEngine, TaskQueue)
export class TreeView {
constructor(element, viewResources, bindingEngine, taskQueue) {
this.element = element;
this.viewResources = viewResources;
this.bindingEngine = bindingEngine;
this.taskQueue = taskQueue;
}
refresh() {
this.treeData = processData(this.data, this.filterFunc);
this.listItemMap = new WeakMap();
this.treeData.forEach(li => this.listItemMap.set(li.item, li));
this.filterChanged(this.filter);
// queue another task, which will execute after the tasks queued above ^^^
this.taskQueue.queueMicroTask(() => {
$(this.element).perfectScrollbar('update');
});
}
}
I'm honestly not sure why this is not working. Seems to be a pretty standard operation. It is not mounting the component, is not throwing an error, and not running the function directly after it. All happing in cfg.AddToCart.vm.addToCart()
cfg.AddToCart = {
vm: {
init() {
return;
},
addToCart() {
let parent = document.getElementById('atc-error');
let errEl = document.getElementById('atc-error-component');
if(cfg.state.selections.SIZE) {
m.mount(errEl, null);
} else {
let component = new cfg.selectComponent(cfg.Options, cfg.optionsView);
m.mount(errEl, component);
cfg.util.toggleSlide(parent);
}
}
},
controller() {
cfg.AddToCart.vm.init();
}
};
cfg.AddToCart.view = function() {
return <div id="add-to-cart-container">
<div id="atc-error">
<span>Select a size and add to cart again.</span>
<div id="atc-error-component"></div>
</div>
<div class="small-12 columns">
<button class="large button alert"
onclick={() => {
this.vm.addToCart();
}}>
Add To Cart
</button>
</div>
</div>;
};
We use the new cfg.selectComponent(cfg.Options, cfg.optionsView) component multiple times throughout the application, so it is not an error with that. #atc-error is set to display:none, but that also doesn't seem to be the problem. This is not the only conditional mount in the application, so that is why I'm a bit stumped.
from looking at the way you've structured your code it strikes me you're missing out on a lot of Mithril's benefits. In particular:
If your 'vm' is indistinguishable from the controller, then you don't need to create and manage a whole separate object for that. Especially when you're using methods to control local component state, that is the job of the controller. The controller exposes an object to the view — this should be considered the 'vm' to that extent. Having a separate object to hold model state is useful when the state is relevant outside of the component instance: you already have this in your cfg.state, so in this scenario the vm is redundant.
Mithril views have a config method which exposes the real DOM element after every draw. You don't need to store references to view elements since you can do it here. This is a large part of what makes virtual DOM libraries so appealing: the view is clever, and you can introduce view-specific logic in them directly.
Components can be called directly from within the view, and the view can use conditional logic to determine whether or not to call them. m.mount is only necessary to initialise a Mithril application and define 'top level' components; from within Mithril code you can invoke nested components via m function directly.
A couple of other misunderstandings:
The controller executes before the view is rendered (and once it's executed, the properties it initialises are exposed to your view function as the first argument), so you can't access elements created by the view when the controller initialises.
The init function in the vm serves no purpose.
Here's a rewrite of your code that takes the above into account. I used plain Mithril instead of MSX to avoid compilation, but you could easily convert it back:
// Determine what your external dependencies are
const { state, selectComponent } = cfg
// Define the component
const AddToCart = {
// No need for a separate VM: it is identical in purpose & function to the controller
controller : function(){
// No need to store element references in the model: those are the view's concern.
// Keep the VM / ctrl size to a minimum by only using it to deal with state
this.addToCart = () => {
if( state.selections.SIZE )
this.showSize = false
else {
this.showSize = true
this.slideToErr = true
}
}
},
view : ctrl =>
m( '#add-to-cart-container',
m( '#atc-error', {
// Config exposes the element and runs after every draw.
config : el => {
// Observe state, and affect the view accordingly:
if( ctrl.slideToErr ){
el.scrollIntoView()
// Reset the state flag
ctrl.slideToErr = false
}
}
},
m( 'span', 'Select a size and add to cart again.' ),
// This is an and condition, ie 'if A, then B
ctrl.showSize
// This is how you invoke a component from within a view
&& m( selectComponent )
),
m( '.small-12 columns',
m( 'button.large button alert', {
onclick : () =>
ctrl.addToCart();
},
'Add To Cart'
)
)
)
}
Worked by changing it to this pattern:
cfg.AddToCart = {
vm: {
init() {
this.errorComponent = m.prop();
},
addToCart() {
let parent = document.getElementById('atc-error');
let errEl = document.getElementById('atc-error-component');
if(cfg.state.selections.SIZE) {
cfg.util.toggleSlide(parent);
setTimeout(() => {
this.errorComponent(null);
}, 400);
} else {
let component = new cfg.selectComponent(cfg.Options, cfg.optionsView);
this.errorComponent(component);
setTimeout(() => {
cfg.util.toggleSlide(parent);
}, 100);
}
}
},
controller() {
cfg.AddToCart.vm.init();
}
};
cfg.AddToCart.view = function() {
return <div id="add-to-cart-container">
<div id="atc-error">
<span>Select a size and add to cart again.</span>
<div id="atc-error-component" class="row">
{this.vm.errorComponent() ? m.component(this.vm.errorComponent()) : ''}
</div>
</div>
<div class="small-12 columns">
<button class="large button alert"
onclick={() => {
this.vm.addToCart();
}}>
Add To Cart
</button>
</div>
</div>;
};