Refactor popups

This commit is contained in:
Kacper Donat 2020-01-19 18:03:38 +01:00
parent 4b389582ad
commit 9802473d7c
21 changed files with 184 additions and 77 deletions

4
.gitignore vendored
View File

@ -14,4 +14,6 @@
/.idea/ /.idea/
/public/* /public/*
!/public/index.php !/public/index.php
!/public/manifest.json !/public/manifest.jso
/yarn-error.log

View File

@ -40,6 +40,7 @@
"copy-webpack-plugin": "^4.5.2", "copy-webpack-plugin": "^4.5.2",
"imagemin-webpack-plugin": "^2.3.0", "imagemin-webpack-plugin": "^2.3.0",
"mini-css-extract-plugin": "^0.4.2", "mini-css-extract-plugin": "^0.4.2",
"portal-vue": "^2.1.7",
"vue2-leaflet": "^1.0.2", "vue2-leaflet": "^1.0.2",
"vuex": "^3.0.1", "vuex": "^3.0.1",
"vuex-class": "^0.3.1", "vuex-class": "^0.3.1",

View File

@ -1,6 +1,14 @@
<div class="input-group"> <form class="favourite-add-form" @submit="save">
<input class="form-control form-control-sm" placeholder="nazwa" v-model="name"/> <label for="favourite_add_name">Nazwa</label>
<button class="btn btn-sm btn-dark" @click="save"> <div class="input-group">
<fa :icon="['fal', 'check']"></fa> <input class="form-control form-control-sm" placeholder="np. Z pracy"
</button> :class="{ 'is-invalid': errors.name.length > 0 }" id="favourite_add_name"
</div> v-model="name" v-autofocus/>
<button class="btn btn-sm btn-dark" type="submit">
<fa :icon="['fal', 'check']"></fa>
</button>
<div v-if="errors.name.length > 0" class="invalid-feedback">
<p v-for="error in errors.name">{{ error }}</p>
</div>
</div>
</form>

View File

@ -1,8 +1,8 @@
<div class="finder"> <div class="finder">
<input class="form-control" v-model="filter" placeholder="Zacznij pisać nazwę aby szukać..."/> <input class="form-control" :value="filter" @input="filter = $event.target.value" placeholder="Zacznij pisać nazwę aby szukać..."/>
<div v-if="filter.length < 3" class="mt-2"> <div v-if="filter.length < 3" class="mt-2">
<favourites></favourites> <favourites />
</div> </div>
<div v-if="state === 'fetching'" class="text-center p-4"> <div v-if="state === 'fetching'" class="text-center p-4">

View File

@ -1,8 +1,6 @@
<div class="d-flex flex-wrap"> <div class="d-flex flex-wrap">
<stop :stop="stop" /> <stop :stop="stop" />
<slot/>
<div class="stop__actions flex-space-left"> <div class="stop__actions flex-space-left">
<slot name="actions"> <slot name="actions">
<button class="btn btn-action" ref="action-info" @click="details = !details"> <button class="btn btn-action" ref="action-info" @click="details = !details">
@ -19,9 +17,9 @@
<stop-details :stop="stop"/> <stop-details :stop="stop"/>
</fold> </fold>
<popper reference="action-map" :visible="map" arrow class="popper--no-padding" style="width: 500px;" placement="right-start"> <popper reference="action-map" v-show="showMap" arrow class="popper--no-padding" style="width: 500px;" placement="right-start" v-hover:inMap>
<div style="height: 300px"> <lazy :activate="showMap">
<stop-map :stop="stop" /> <stop-map :stop="stop" style="height: 300px"/>
</div> </lazy>
</popper> </popper>
</div> </div>

View File

@ -1,7 +1,4 @@
<div class="popper" :class="{ 'popper--arrow': arrow, 'd-none': !show }" @focusin="focused = true" @focusout="focused = false" v-hover="hovered"> <div class="popper" :class="{ 'popper--arrow': arrow }" v-on="listeners">
<div class="popper__arrow" ref="arrow" v-if="arrow"></div> <div class="popper__arrow" ref="arrow" v-if="arrow"></div>
<lazy v-if="lazy" :activate="show"> <slot />
<slot></slot>
</lazy>
<slot v-else></slot>
</div> </div>

View File

@ -1,5 +1,7 @@
<l-map :center="stop.location" :zoom=17 :options="{ zoomControl: false, dragging: false }"> <div>
<l-tile-layer url="//{s}.tile.osm.org/{z}/{x}/{y}.png" <l-map :center="stop.location" :zoom=17 :options="{ zoomControl: false, dragging: false }">
attribution='&copy; <a href="//osm.org/copyright">OpenStreetMap</a> contributors'/> <l-tile-layer url="//{s}.tile.osm.org/{z}/{x}/{y}.png"
<l-marker :lat-lng="stop.location"/> attribution='&copy; <a href="//osm.org/copyright">OpenStreetMap</a> contributors'/>
</l-map> <l-marker :lat-lng="stop.location"/>
</l-map>
</div>

View File

@ -109,3 +109,7 @@ svg.svg-inline--fa {
.icon { .icon {
padding: .5rem 0.75rem; padding: .5rem 0.75rem;
} }
.invalid-feedback p {
margin-bottom: 0;
}

View File

@ -0,0 +1,5 @@
@include media-breakpoint-up('sm') {
.favourite-add-form {
width: 250px;
}
}

View File

@ -0,0 +1,7 @@
label {
font-weight: bold;
font-size: .8rem;
margin-bottom: 0;
margin-top: -0.2rem;
display: block;
}

View File

@ -114,3 +114,10 @@
@include placement("bottom"); @include placement("bottom");
} }
} }
@include media-breakpoint-down('sm') {
.popper {
margin-left: $spacer;
margin-right: $spacer;
}
}

View File

@ -10,7 +10,6 @@ $primary: #005ea8;
$custom-control-indicator-checked-bg: $dark; $custom-control-indicator-checked-bg: $dark;
$custom-control-indicator-active-bg: $dark; $custom-control-indicator-active-bg: $dark;
$line-types: ( $line-types: (
'trolleybus': #419517, 'trolleybus': #419517,
'tram': #cd2e12, 'tram': #cd2e12,
@ -27,6 +26,7 @@ $headings-margin-bottom: $default-spacing;
$container-max-widths: map-merge($container-max-widths, ( xl: 1320px )); $container-max-widths: map-merge($container-max-widths, ( xl: 1320px ));
$link-color: #005ea8; $link-color: #005ea8;
$grid-gutter-width: $spacer * 2;
@import "~bootstrap/scss/bootstrap"; @import "~bootstrap/scss/bootstrap";
@ -49,6 +49,8 @@ $link-color: #005ea8;
@import "controls"; @import "controls";
@import "popper"; @import "popper";
@import "animations"; @import "animations";
@import "form";
@import "fabourites";
body { body {
min-height: 100vh; min-height: 100vh;

View File

@ -13,9 +13,11 @@ window['Popper'] = Popper;
// dependencies // dependencies
import Vue from "vue"; import Vue from "vue";
import Vuex from 'vuex'; import Vuex from 'vuex';
import PortalVue from 'portal-vue';
import { Workbox } from "workbox-window"; import { Workbox } from "workbox-window";
Vue.use(Vuex); Vue.use(Vuex);
Vue.use(PortalVue);
// async dependencies // async dependencies
(async function () { (async function () {

View File

@ -19,6 +19,7 @@ export class FavouritesComponent extends Vue {
@Component({ template: require('../../components/favourites/save.html' )}) @Component({ template: require('../../components/favourites/save.html' )})
export class FavouritesAdderComponent extends Vue { export class FavouritesAdderComponent extends Vue {
private name = ""; private name = "";
private errors = { name: [] };
@Mutation add: (fav: Favourite) => void; @Mutation add: (fav: Favourite) => void;
@ -28,10 +29,28 @@ export class FavouritesAdderComponent extends Vue {
const favourite: Favourite = { name, state }; const favourite: Favourite = { name, state };
this.add(favourite); if (this.validate(favourite)) {
this.name = ''; this.add(favourite);
this.name = '';
this.$emit('saved', favourite); this.$emit('saved', favourite);
}
}
private validate(favourite: Favourite) {
let errors = { name: [] };
if (favourite.name.length == 0) {
errors.name.push("Musisz podać nazwę.");
}
if (this.$store.state.favourites.favourites.filter(other => other.name == favourite.name).length > 0) {
errors.name.push("Istnieje już zapisana grupa przystanków o takiej nazwie.");
}
this.errors = errors;
return Object.entries(errors).map(a => a[1]).reduce((acc, cur) => [ ...acc, ...cur ]).length == 0;
} }
} }

View File

@ -13,6 +13,11 @@ export class PickerStopComponent extends Vue {
details: boolean = false; details: boolean = false;
map: boolean = false; map: boolean = false;
inMap: boolean = false;
get showMap() {
return this.inMap || this.map;
}
} }
@Component({ @Component({

View File

@ -1,35 +1,37 @@
import Vue from 'vue'; import Vue from 'vue';
import { Component, Prop, Watch } from "vue-property-decorator"; import { Component, Prop, Watch } from "vue-property-decorator";
import Popper, { Placement } from "popper.js"; import Popper, { Placement } from "popper.js";
import { Portal } from "portal-vue";
@Component({ template: require("../../components/popper.html") }) @Component({
template: require("../../components/popper.html")
})
export class PopperComponent extends Vue { export class PopperComponent extends Vue {
@Prop(String) @Prop(String)
public reference: string; public reference: string;
@Prop(Object)
public refs: string;
@Prop({ type: String, default: "auto" }) @Prop({ type: String, default: "auto" })
public placement: Placement; public placement: Placement;
@Prop(Boolean) @Prop(Boolean)
public arrow: boolean; public arrow: boolean;
@Prop({ type: Boolean, default: false }) private _event;
public visible: boolean;
@Prop(Boolean)
public lazy: boolean;
public hovered: boolean = false;
public focused: boolean = false;
private _popper; private _popper;
get show() { focusOut(event: MouseEvent) {
return this.visible || this.hovered || this.focused; if (this.$el.contains(event.target as Node)) {
return;
}
this.$emit('leave', event);
} }
mounted() { mounted() {
const reference = this.$parent.$refs[this.reference] as HTMLElement; const reference = this.refsSource[this.reference] as HTMLElement;
this._popper = new Popper(reference, this.$el, { this._popper = new Popper(reference, this.$el, {
placement: this.placement, placement: this.placement,
@ -42,8 +44,6 @@ export class PopperComponent extends Vue {
if (window.innerWidth < 560) { if (window.innerWidth < 560) {
data.instance.options.placement = 'bottom'; data.instance.options.placement = 'bottom';
data.styles.transform = `translate3d(0, ${data.offsets.popper.top}px, 0)`; data.styles.transform = `translate3d(0, ${data.offsets.popper.top}px, 0)`;
data.styles.width = '100%';
data.styles.margin = '0';
data.styles.right = '0'; data.styles.right = '0';
data.styles.left = '0'; data.styles.left = '0';
data.styles.width = 'auto'; data.styles.width = 'auto';
@ -56,13 +56,20 @@ export class PopperComponent extends Vue {
} }
}); });
this.$nextTick(() => this._popper.update()) this.$nextTick(() => {
this._popper.update();
document.addEventListener('click', this._event = this.focusOut.bind(this), { capture: true });
});
} }
updated() { updated() {
this._popper.update(); this._popper.update();
} }
get listeners() {
return { ...this.$listeners, focusout: this.focusOut }
}
@Watch('visible') @Watch('visible')
private onVisibilityUpdate() { private onVisibilityUpdate() {
this._popper.update(); this._popper.update();
@ -71,6 +78,19 @@ export class PopperComponent extends Vue {
beforeDestroy() { beforeDestroy() {
this._popper.destroy(); this._popper.destroy();
this._event && document.removeEventListener('click', this._event, { capture: true });
}
get refsSource() {
if (this.refs) {
return this.refs;
}
if (this.$parent.$options.name == 'portalTarget') {
return this.$parent.$parent.$refs;
}
return this.$parent.$refs
} }
} }

View File

@ -45,6 +45,20 @@ Vue.directive('hover', {
} }
}); });
Vue.directive('autofocus', {
inserted(el, binding) {
if (binding.value !== undefined) {
const value = binding.value;
if ((typeof value === "boolean" && !value) || (typeof value === "function" && !value(el))) {
return;
}
}
el.focus();
}
});
Vue.directive('responsive', { Vue.directive('responsive', {
inserted(el, binding) { inserted(el, binding) {
const breakpoints = typeof binding.value === 'object' ? binding.value : { const breakpoints = typeof binding.value === 'object' ? binding.value : {

View File

@ -51,7 +51,13 @@ class ZtmGdanskDepartureRepository implements DepartureRepository
private function getRealDepartures(Stop $stop) private function getRealDepartures(Stop $stop)
{ {
$estimates = json_decode(file_get_contents(static::ESTIMATES_URL . "?stopId=" . $stop->getId()), true)['delay']; try {
$estimates = file_get_contents(static::ESTIMATES_URL . "?stopId=" . $stop->getId());
$estimates = json_decode($estimates, true)['delay'];
} catch (\Error $e) {
return collect();
}
$estimates = collect($estimates); $estimates = collect($estimates);
$lines = $estimates->map(function ($delay) { $lines = $estimates->map(function ($delay) {

View File

@ -11,7 +11,7 @@
<fa :icon="['fal', 'bullhorn']" fixed-width class="mr-2"></fa> <fa :icon="['fal', 'bullhorn']" fixed-width class="mr-2"></fa>
Komunikaty <span class="ml-2 badge badge-pill badge-dark">{{ '{{ messages.count }}' }}</span> Komunikaty <span class="ml-2 badge badge-pill badge-dark">{{ '{{ messages.count }}' }}</span>
</h2> </h2>
<button class="btn btn-action flex-space-left" ref="settings-messages" v-hover="visibility.messages"> <button class="btn btn-action flex-space-left" ref="settings-messages" @click="visibility.messages = true">
<fa :icon="['fal', 'cog']" fixed-width></fa> <fa :icon="['fal', 'cog']" fixed-width></fa>
</button> </button>
<button class="btn btn-action" @click="updateMessages" ref="btn-messages-refresh"> <button class="btn btn-action" @click="updateMessages" ref="btn-messages-refresh">
@ -21,7 +21,7 @@
<fa :icon="['fal', sections.messages ? 'chevron-up' : 'chevron-down']" fixed-width/> <fa :icon="['fal', sections.messages ? 'chevron-up' : 'chevron-down']" fixed-width/>
</button> </button>
<popper reference="settings-messages" :visible="visibility.messages" arrow placement="left-start"> <popper reference="settings-messages" v-if="visibility.messages" arrow placement="left-start" @leave="visibility.messages = false">
<h3 class="popper__heading flex"> <h3 class="popper__heading flex">
<fa :icon="['far', 'cog']"></fa> <fa :icon="['far', 'cog']"></fa>
<label class="text" for="messages-auto-refresh">autoodświeżanie</label> <label class="text" for="messages-auto-refresh">autoodświeżanie</label>
@ -47,26 +47,27 @@
<span class="text">Odjazdy</span> <span class="text">Odjazdy</span>
</h2> </h2>
<button class="btn btn-action flex-space-left" ref="settings-departures" v-hover="visibility.departures"> <button class="btn btn-action flex-space-left" ref="settings-departures" @click="visibility.departures = true">
<fa :icon="['fal', 'cog']" fixed-width></fa> <fa :icon="['fal', 'cog']" fixed-width></fa>
</button> </button>
<button class="btn btn-action" @click="updateDepartures({ stops })"> <button class="btn btn-action" @click="updateDepartures({ stops })">
<fa :icon="['fal', 'sync']" :spin="departures.state === 'fetching'" fixed-width></fa> <fa :icon="['fal', 'sync']" :spin="departures.state === 'fetching'" fixed-width></fa>
</button> </button>
<portal to="popups">
<popper reference="settings-departures" :visible="visibility.departures" arrow placement="left-start"> <popper reference="settings-departures" v-if="visibility.departures" arrow placement="left-start" @leave="visibility.departures = false">
<h3 class="popper__heading flex"> <h3 class="popper__heading flex">
<fa :icon="['far', 'sync']" fixed-width></fa> <fa :icon="['far', 'sync']" fixed-width></fa>
<label class="text" for="messages-auto-refresh">autoodświeżanie</label> <label class="text" for="messages-auto-refresh">autoodświeżanie</label>
<input type="checkbox" class="flex-space-left" id="messages-auto-refresh" v-model="autorefresh.departures.active"/> <input type="checkbox" class="flex-space-left" id="messages-auto-refresh" v-model="autorefresh.departures.active"/>
</h3> </h3>
<div class="flex" v-show="autorefresh.messages.active"> <div class="flex" v-show="autorefresh.messages.active">
<span class="text">co</span> <span class="text">co</span>
<label class="sr-only" for="messages-auto-refresh-interval">częstotliwość odświeżania</label> <label class="sr-only" for="messages-auto-refresh-interval">częstotliwość odświeżania</label>
<input type="text" class="form-control form-control-sm form-control-simple" id="messages-auto-refresh-interval" v-model="autorefresh.departures.interval"/> <input type="text" class="form-control form-control-sm form-control-simple" id="messages-auto-refresh-interval" v-model="autorefresh.departures.interval"/>
<span class="text">s</span> <span class="text">s</span>
</div> </div>
</popper> </popper>
</portal>
</header> </header>
<departures :stops="stops"></departures> <departures :stops="stops"></departures>
{% if provider.attribution %} {% if provider.attribution %}
@ -87,17 +88,6 @@
<button class="btn btn-action flex-space-left" @click="clear"> <button class="btn btn-action flex-space-left" @click="clear">
<fa :icon="['fal', 'trash-alt']" fixed-width></fa> <fa :icon="['fal', 'trash-alt']" fixed-width></fa>
</button> </button>
<button class="btn btn-action" @click="visibility.save = true" @focusout="visibility.save = false" ref="save">
<fa :icon="['fal', 'star']" fixed-width></fa>
</button>
<popper reference="save" :visible="visibility.save" arrow>
<h3 class="popper__heading flex">
<fa :icon="['far', 'star']" fixed-width></fa>
<span class="text">Dodaj do ulubionych</span>
</h3>
<favourites-adder></favourites-adder>
</popper>
</header> </header>
<ul class="picker__stops list-underlined"> <ul class="picker__stops list-underlined">
@ -108,6 +98,17 @@
<picker-stop :stop="stop" class="flex-grow-1"></picker-stop> <picker-stop :stop="stop" class="flex-grow-1"></picker-stop>
</li> </li>
</ul> </ul>
<div class="d-flex mt-2">
<button class="btn btn-action btn-sm flex-space-left" @click="visibility.save = true" ref="save">
<fa :icon="['fal', 'star']" fixed-width></fa>
zapisz jako...
</button>
</div>
<popper reference="save" v-if="visibility.save" arrow tabindex="-1" @leave="visibility.save = false">
<favourites-adder @saved="visibility.save = false"/>
</popper>
</section> </section>
<section class="section picker"> <section class="section picker">
<header class="section__title flex"> <header class="section__title flex">
@ -130,7 +131,7 @@
</button> </button>
</template> </template>
</header> </header>
<div class="transition-box"> <div class="transition-box" style="overflow: hidden;">
<transition name="fade"> <transition name="fade">
<keep-alive> <keep-alive>
<stop-finder @select="add" :blacklist="stops" v-if="visibility.picker === 'search'"></stop-finder> <stop-finder @select="add" :blacklist="stops" v-if="visibility.picker === 'search'"></stop-finder>
@ -141,6 +142,8 @@
</section> </section>
</div> </div>
</div> </div>
<portal-target name="popups" multiple></portal-target>
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}

View File

@ -4782,6 +4782,11 @@ popper.js@*, popper.js@^1.14.1, popper.js@^1.14.4:
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.0.tgz#2e1816bcbbaa518ea6c2e15a466f4cb9c6e2fbb3" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.0.tgz#2e1816bcbbaa518ea6c2e15a466f4cb9c6e2fbb3"
integrity sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw== integrity sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw==
portal-vue@^2.1.7:
version "2.1.7"
resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-2.1.7.tgz#ea08069b25b640ca08a5b86f67c612f15f4e4ad4"
integrity sha512-+yCno2oB3xA7irTt0EU5Ezw22L2J51uKAacE/6hMPMoO/mx3h4rXFkkBkT4GFsMDv/vEe8TNKC3ujJJ0PTwb6g==
posix-character-classes@^0.1.0: posix-character-classes@^0.1.0:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"