Basic Modal system

This commit is contained in:
Kacper Donat 2020-09-26 16:07:58 +02:00
parent 452a5c4993
commit e0574615a7
17 changed files with 341 additions and 145 deletions

View File

@ -1,4 +1,4 @@
<form class="favourite-add-form" @submit="save">
<form class="favourite-add-form" @submit.prevent="save">
<div class="form-group">
<label for="favourite_add_name">Nazwa</label>
<div class="input-group">

View File

@ -35,8 +35,8 @@
</fold>
<keep-alive>
<popper reference="action-map" v-if="showMap" arrow class="popper--no-padding" style="width: 500px;" placement="right-start" v-hover:inMap>
<ui-dialog reference="action-map" v-if="showMap" arrow class="ui-popup--no-padding" style="width: 500px;" placement="right-start" v-hover:inMap>
<stop-map :stop="stop" style="height: 300px"/>
</popper>
</ui-dialog>
</keep-alive>
</div>

View File

@ -1,4 +0,0 @@
<div :class="[ 'popper', arrow && 'popper--arrow' ]" v-on="$listeners">
<div class="popper__arrow" ref="arrow" v-if="arrow"></div>
<slot />
</div>

View File

@ -1,9 +1,9 @@
<fragment>
<portal to="popups">
<transition name="tooltip">
<popper class="popper--tooltip" aria-hidden="true" arrow :reference="root" :placement="placement" v-if="show" :responsive="false">
<ui-dialog class="ui-popup--tooltip" aria-hidden="true" arrow :reference="root" :placement="placement" v-if="show" :responsive="false">
<slot />
</popper>
</ui-dialog>
</transition>
</portal>
<span ref="root" class="sr-only"><slot /></span>

View File

@ -0,0 +1,29 @@
<div class="ui-backdrop" @click="handleBackdropClick" v-if="currentBehaviour === 'modal'">
<div class="ui-modal" v-bind="$attrs">
<div class="ui-modal__top-bar">
<div class="ui-modal__header">
<slot name="header">
<div class="ui-modal__title">{{ title }}</div>
</slot>
</div>
<button class="btn btn-action ui-modal__close" @click.prevent="handleCloseClick">
<ui-icon icon="close"/>
</button>
</div>
<slot />
<div class="ui-modal__footer" v-if="hasFooter">
<slot name="footer" />
</div>
</div>
</div>
<div :class="[ 'ui-popup', arrow && 'ui-popup--arrow' ]" v-bind="$attrs" v-on="$listeners" v-else>
<div class="ui-popup__arrow" ref="arrow" v-if="arrow"></div>
<div class="ui-popup__header" v-if="hasHeader">
<slot name="header" />
</div>
<slot />
<div class="ui-popup__footer" v-if="hasFooter">
<slot name="footer" />
</div>
</div>

View File

@ -1,5 +1,5 @@
.map__label-box {
@extend .popper;
@extend .ui-popup;
padding: .5rem;
background: white;

View File

@ -75,12 +75,24 @@ $grid-gutter-width: $spacer * 2;
}
}
@mixin position($position, $top: inherit, $right: inherit, $bottom: inherit, $left: inherit) {
$right: if($right == inherit, $top, $right);
$bottom: if($bottom == inherit, $top, $bottom);
$left: if($left == inherit, $right, $left);
position: $position;
top: $top;
right: $right;
left: $left;
bottom: $bottom;
}
@import "common";
@import "stop";
@import "departure";
@import "line";
@import "controls";
@import "popper";
@import "animations";
@import "form";
@import "favourites";
@ -89,6 +101,8 @@ $grid-gutter-width: $spacer * 2;
@import "map";
@import "ui/switch";
@import "ui/popup";
@import "ui/modal";
@import "page/provider-picker";

View File

@ -8,7 +8,7 @@
}
.provider-picker {
@extend .popper;
@extend .ui-popup;
padding: 1rem;
margin: 3rem;
}

View File

@ -0,0 +1,58 @@
.ui-backdrop {
@include position(fixed, 0);
background: rgba(black, .75);
padding: $spacer;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
overscroll-behavior-y: contain;
&::after {
height: 1rem;
display: block;
content: "";
}
}
$dialog-margin: 1rem;
$dialog-sizes: (
medium: 480px,
small: 320px,
large: 640px,
)
;
.ui-modal {
padding: $dialog-margin;
background: white;
margin: auto;
box-shadow: rgba(black, .7) 0 1px 3px;
border-radius: 1px;
@each $size, $width in $dialog-sizes {
.ui-modal--#{$size} {
width: $width;
}
}
}
.ui-modal__close {
margin-right: -$dialog-margin;
padding: $dialog-margin $dialog-margin 0;
margin-top: -$dialog-margin;
}
.ui-modal__header {
flex: 1 1 auto;
}
.ui-modal__title {
font-weight: bold;
font-size: 0.875rem;
}
.ui-modal__top-bar {
display: flex;
margin-bottom: $dialog-margin * 0.75;
}

View File

@ -54,7 +54,7 @@
@mixin triangle-left($size, $color, $border: none) { @include triangle(left, $size, $color, $border); }
@mixin triangle-right($size, $color, $border: none) { @include triangle(right, $size, $color, $border); }
.popper {
.ui-popup {
$arrow-base: 8px;
$arrow-color: white;
$arrow-border: rgba(black, 0.2);
@ -74,17 +74,17 @@
border-radius: 2px;
.popper__arrow {
.ui-popup__arrow {
position: absolute;
width: 0;
height: 0;
}
&.popper--no-padding {
&.ui-popup--no-padding {
padding: 0;
}
.popper__heading {
.ui-popup__heading {
font-size: $font-size-sm;
font-weight: bold;
margin-bottom: .5rem;
@ -105,7 +105,7 @@
&[x-placement*="#{$placement}"] {
margin-#{map-get($opposite, $placement)}: $arrow-base;
.popper__arrow {
.ui-popup__arrow {
#{map-get($opposite, $placement)}: 0;
@include triangle(map-get($opposite, $placement), $arrow-base, $arrow-color, $arrow-border);
}
@ -119,11 +119,11 @@
@include placement("bottom");
}
&.popper--arrow {
&.ui-popup--arrow {
@include arrows;
}
&.popper--tooltip {
&.ui-popup--tooltip {
background: $dark;
color: white;
padding: .5rem .75rem;
@ -132,14 +132,14 @@
min-width: 0;
box-shadow: none;
&.popper--arrow {
&.ui-popup--arrow {
$arrow-color: $dark;
$arrow-border: none;
$arrow-base: 6px;
@include arrows;
.popper__arrow::before {
.ui-popup__arrow::before {
border: none;
}
}
@ -147,7 +147,7 @@
}
@include media-breakpoint-down('sm') {
.popper {
.ui-popup {
margin-left: $spacer;
margin-right: $spacer;
}

View File

@ -31,10 +31,14 @@ Vue.use(VueMoment, { moment });
declare module 'vue/types/vue' {
interface Vue {
$isTouch: boolean;
$hasSlot: (slot: string) => string;
}
}
Vue.prototype.$isTouch = 'ontouchstart' in window || navigator.msMaxTouchPoints > 0;
Vue.prototype.$hasSlot = function (this: Vue, slot: string): boolean {
return !!this.$slots[slot] || !!this.$scopedSlots[slot];
}
Component.registerHooks(['removed']);

View File

@ -0,0 +1,187 @@
import Vue from "vue";
import { Component, Prop, Watch } from "vue-property-decorator";
import Popper, { Placement } from "popper.js";
import { defaultBreakpoints } from "../../filters";
/**
* How popup will be presented to user:
* - "modal" - modal window
* - "popup" - simple popup
*/
export type DialogBehaviour = "modal" | "popup";
@Component({
template: require('../../../components/ui/dialog.html'),
inheritAttrs: false,
})
export default class UiDialog extends Vue {
@Prop({ type: String, default: "popup" })
private behaviour: DialogBehaviour;
@Prop({ type: String })
private mobileBehaviour: DialogBehaviour;
@Prop([String, HTMLElement])
public reference: string | HTMLElement;
@Prop(Object)
public refs: string;
@Prop({ type: String, default: "auto" })
public placement: Placement;
@Prop(Boolean)
public arrow: boolean;
@Prop({ type: Boolean, default: true })
public responsive: boolean;
@Prop(String)
public title: string;
private isMobile: boolean = false;
private _focusOutEvent;
private _resizeEvent;
private _popper;
get currentBehaviour(): DialogBehaviour {
if (!this.mobileBehaviour) {
return this.behaviour;
}
return this.isMobile ? this.mobileBehaviour : this.behaviour;
}
get hasFooter() {
return this.$hasSlot('footer')
}
get hasHeader() {
return this.$hasSlot('header')
}
private getReferenceElement() {
const isInWrapper = this.$parent.$options.name == 'portalTarget';
if (typeof this.reference === 'string') {
if (this.refs) {
return this.refs[this.reference];
}
if (isInWrapper) {
return this.$parent.$parent.$refs[this.reference];
}
return this.$parent.$refs[this.reference];
}
if (this.reference instanceof HTMLElement) {
return this.reference;
}
return isInWrapper ? this.$parent.$el : this.$el.parentElement;
}
focusOut(event: MouseEvent) {
if (this.$el.contains(event.target as Node)) {
return;
}
this.$emit('leave', event);
}
mounted() {
this.handleWindowResize();
if (this.behaviour === 'popup') {
this.initPopper();
}
window.addEventListener('resize', this._resizeEvent = this.handleWindowResize.bind(this));
}
private initPopper() {
const reference = this.getReferenceElement();
this._popper = new Popper(reference, this.$el, {
placement: this.placement,
modifiers: {
arrow: { enabled: this.arrow, element: this.$refs['arrow'] as Element },
responsive: {
enabled: this.responsive,
order: 890,
fn(data) {
if (window.innerWidth < 560) {
data.instance.options.placement = 'top';
data.styles.transform = `translate3d(0, ${ data.offsets.popper.top }px, 0)`;
data.styles.right = '0';
data.styles.left = '0';
data.styles.width = 'auto';
data.arrowStyles.left = `${ data.offsets.popper.left + data.offsets.arrow.left }px`;
}
return data;
}
}
}
});
this.$nextTick(() => {
this._popper && this._popper.update();
document.addEventListener('click', this._focusOutEvent = this.focusOut.bind(this), { capture: true });
});
}
private removePopper() {
this._popper.destroy()
this._popper = null;
}
updated() {
if (this._popper) {
this._popper.update();
}
}
beforeDestroy() {
this._focusOutEvent && document.removeEventListener('click', this._focusOutEvent, { capture: true });
}
removed() {
if (this._popper) {
this.removePopper();
}
}
private handleBackdropClick(ev: Event) {
const target = ev.target as HTMLElement;
if (target.classList.contains("ui-backdrop")) {
this.$emit('leave');
}
}
private handleCloseClick() {
this.$emit('leave');
this.$emit('close');
}
private handleWindowResize() {
this.isMobile = screen.width < defaultBreakpoints.md;
}
@Watch('currentBehaviour')
private handleBehaviourChange(newBehaviour: DialogBehaviour, oldBehaviour: DialogBehaviour) {
if (oldBehaviour === 'popup') {
this.removePopper();
}
if (newBehaviour === 'popup') {
this.$nextTick(() => this.initPopper());
}
}
}
Vue.component("ui-dialog", UiDialog);

View File

@ -7,7 +7,8 @@ import {
faCheck,
faCheckDouble,
faChevronCircleUp,
faChevronDown, faChevronUp,
faChevronDown,
faChevronUp,
faClock,
faCog,
faExclamationTriangle,
@ -15,7 +16,8 @@ import {
faInfoCircle,
faMapMarkerAlt,
faMoon,
faQuestionCircle, faQuestionSquare,
faQuestionCircle,
faQuestionSquare,
faSearch,
faSign,
faStar,
@ -23,7 +25,13 @@ import {
faTimes,
faTrashAlt
} from "@fortawesome/pro-light-svg-icons";
import { faClock as faClockBold, faCodeCommit, faMinus, faPlus, faSpinnerThird } from "@fortawesome/pro-regular-svg-icons";
import {
faClock as faClockBold,
faCodeCommit,
faMinus,
faPlus,
faSpinnerThird
} from "@fortawesome/pro-regular-svg-icons";
import { faExclamationTriangle as faSolidExclamationTriangle, faWalking } from "@fortawesome/pro-solid-svg-icons";
import { fac } from "../../icons";
import { FontAwesomeIcon, FontAwesomeLayers, FontAwesomeLayersText } from "@fortawesome/vue-fontawesome";
@ -88,6 +96,7 @@ const definitions: Dictionary<Icon> = {
{icon: faClockBold},
{icon: faSolidExclamationTriangle, transform: "shrink-5 down-4 right-6"}
]),
'close': simple(faTimes),
...lineTypeIcons,
...messageTypeIcons,
};

View File

@ -1,3 +1,4 @@
export * from './switch';
export * from './icon';
export * from './numeric-input'
export * from './dialog'

View File

@ -1,111 +1,6 @@
import Vue from 'vue';
import { Component, Prop, Watch } from "vue-property-decorator";
import Popper, { Placement } from "popper.js";
import vueRemovedHookMixin from "vue-removed-hook-mixin";
@Component({
template: require("../../components/popper.html"),
mixins: [ vueRemovedHookMixin ]
})
export class PopperComponent extends Vue {
@Prop([ String, HTMLElement ])
public reference: string | HTMLElement;
@Prop(Object)
public refs: string;
@Prop({ type: String, default: "auto" })
public placement: Placement;
@Prop(Boolean)
public arrow: boolean;
@Prop({ type: Boolean, default: true })
public responsive: boolean;
private _event;
private _popper;
private getReferenceElement() {
const isInPortal = this.$parent.$options.name == 'portalTarget';
if (typeof this.reference === 'string') {
if (this.refs) {
return this.refs[this.reference];
}
if (isInPortal) {
return this.$parent.$parent.$refs[this.reference];
}
return this.$parent.$refs[this.reference];
}
if (this.reference instanceof HTMLElement) {
return this.reference;
}
return isInPortal ? this.$parent.$el : this.$el.parentElement;
}
focusOut(event: MouseEvent) {
if (this.$el.contains(event.target as Node)) {
return;
}
this.$emit('leave', event);
}
mounted() {
const reference = this.getReferenceElement();
this._popper = new Popper(reference, this.$el, {
placement: this.placement,
modifiers: {
arrow: { enabled: this.arrow, element: this.$refs['arrow'] as Element },
responsive: {
enabled: this.responsive,
order: 890,
fn(data) {
if (window.innerWidth < 560) {
data.instance.options.placement = 'top';
data.styles.transform = `translate3d(0, ${data.offsets.popper.top}px, 0)`;
data.styles.right = '0';
data.styles.left = '0';
data.styles.width = 'auto';
data.arrowStyles.left = `${data.offsets.popper.left + data.offsets.arrow.left}px`;
}
return data;
}
}
}
});
this.$nextTick(() => {
this._popper.update();
document.addEventListener('click', this._event = this.focusOut.bind(this), { capture: true });
});
}
updated() {
this._popper.update();
}
@Watch('visible')
private onVisibilityUpdate() {
this._popper.update();
window.dispatchEvent(new Event('resize'));
}
beforeDestroy() {
this._event && document.removeEventListener('click', this._event, { capture: true });
}
removed() {
this._popper.destroy()
}
}
@Component({ template: require('../../components/fold.html') })
export class FoldComponent extends Vue {
@ -151,7 +46,6 @@ export class LazyComponent extends Vue {
}
}
Vue.component('Popper', PopperComponent);
Vue.component('Fold', FoldComponent);
Vue.component('Lazy', LazyComponent);

View File

@ -2,6 +2,14 @@ import { set, signed } from "./utils";
import Vue from 'vue';
import { condition } from "./decorators";
export const defaultBreakpoints = {
'xs': 0,
'sm': 576,
'md': 768,
'lg': 1024,
'xl': 1200,
}
Vue.filter('signed', signed);
Vue.directive('hover', {
@ -61,13 +69,7 @@ Vue.directive('autofocus', {
Vue.directive('responsive', {
inserted(el, binding) {
const breakpoints = typeof binding.value === 'object' ? binding.value : {
'xs': 0,
'sm': 576,
'md': 768,
'lg': 1024,
'xl': 1200,
};
const breakpoints = typeof binding.value === 'object' ? binding.value : defaultBreakpoints;
const resize = binding['resize'] = () => {
const width = el.scrollWidth;

View File

@ -29,9 +29,9 @@
</button>
<portal to="popups">
<popper reference="settings-messages" v-if="visibility.messages" arrow placement="left-start" @leave="visibility.messages = false">
<ui-dialog reference="settings-messages" v-if="visibility.messages" arrow placement="left-start" @leave="visibility.messages = false">
<settings-messages></settings-messages>
</popper>
</ui-dialog>
</portal>
</header>
<fold :visible="sections.messages">
@ -54,9 +54,9 @@
<ui-icon icon="refresh" :spin="departures.state === 'fetching'" fixed-width></ui-icon>
</button>
<portal to="popups">
<popper reference="settings-departures" v-if="visibility.departures" arrow placement="left-start" @leave="visibility.departures = false">
<ui-dialog reference="settings-departures" v-if="visibility.departures" @leave="visibility.departures = false" arrow placement="left-start">
<settings-departures></settings-departures>
</popper>
</ui-dialog>
</portal>
</header>
<departures :stops="stops" v-if="stops.length > 0"></departures>
@ -105,9 +105,11 @@
</button>
</div>
<popper reference="save" v-if="visibility.save" arrow tabindex="-1" @leave="visibility.save = false" placement="bottom-end">
<favourites-adder @saved="visibility.save = false"/>
</popper>
<portal to="popups">
<ui-dialog reference="settings-messages" v-if="visibility.messages" arrow placement="left-start" @leave="visibility.messages = false">
<favourites-adder @saved="visibility.save = false"/>
</ui-dialog>
</portal>
</section>
<section class="section picker">
<header class="section__title flex">