Add v-modal component (#274)

* Start on v-modal, fix layering in v-dialog

* Add shadow to v-dialog content

* Add optional group identifier to groupable compositions

This allows groupable parents to be nested more flexibly:

tabs group
  item group
    item
      tab

In the case above, we only want the tab to trigger for tabs group, not item group.

* Add active prop support to v-list-item

Allows us to manually indicate that a list item is active, useful in v-menu

* Use v-list in vertical tabs

* Finish v-modal

* Update readme for groupable composition
This commit is contained in:
Rijk van Zanten
2020-03-31 11:33:34 -04:00
committed by GitHub
parent f0679ae2e1
commit d72b8bbcd7
14 changed files with 459 additions and 66 deletions

View File

@@ -10,6 +10,7 @@ import VDialog from './v-dialog';
import VDivider from './v-divider';
import VForm from './v-form';
import VHover from './v-hover/';
import VModal from './v-modal/';
import VIcon from './v-icon/';
import VInput from './v-input/';
import VItemGroup, { VItem } from './v-item-group';
@@ -48,6 +49,7 @@ Vue.component('v-dialog', VDialog);
Vue.component('v-divider', VDivider);
Vue.component('v-form', VForm);
Vue.component('v-hover', VHover);
Vue.component('v-modal', VModal);
Vue.component('v-icon', VIcon);
Vue.component('v-input', VInput);
Vue.component('v-item-group', VItemGroup);

View File

@@ -28,13 +28,15 @@ export default defineComponent({
.v-card {
--v-card-min-width: none;
--v-card-max-width: 400px;
--v-card-height: auto;
--v-card-min-height: none;
--v-card-max-height: none;
--v-card-max-height: min-content;
--v-card-padding: 16px;
--v-card-background-color: var(--highlight);
min-width: var(--v-card-min-width);
max-width: var(--v-card-max-width);
height: var(--v-card-height);
min-height: var(--v-card-min-height);
max-height: var(--v-card-max-height);
background-color: var(--v-card-background-color);

View File

@@ -3,12 +3,12 @@
<slot name="activator" v-bind="{ on: () => $emit('toggle', true) }" />
<portal to="dialog-outlet">
<div class="container" :class="[{ active }, className]">
<v-overlay :active="active" absolute @click="emitToggle" />
<div class="content">
<transition name="dialog">
<div v-if="active" class="container" :class="[className]">
<v-overlay active absolute @click="emitToggle" />
<slot />
</div>
</div>
</transition>
</portal>
</div>
</template>
@@ -56,6 +56,8 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.v-dialog {
--v-dialog-z-index: 100;
@@ -72,9 +74,7 @@ export default defineComponent({
justify-content: center;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity var(--medium) var(--transition);
pointer-events: none;
.v-card {
--v-card-min-width: 400px;
@@ -90,29 +90,14 @@ export default defineComponent({
--v-overlay-z-index: 1;
}
.content {
position: relative;
z-index: 2;
max-height: 90%;
transform: translateY(-50px);
opacity: 0;
transition: var(--medium) var(--transition-in);
transition-property: opacity, transform;
}
&.active {
opacity: 1;
pointer-events: all;
.content {
transform: translateY(-100px);
opacity: 1;
}
}
&.nudge {
animation: nudge 200ms;
}
::v-deep > * {
z-index: 2;
box-shadow: 0px 4px 12px rgba(38, 50, 56, 0.1);
}
}
@keyframes nudge {
@@ -128,4 +113,24 @@ export default defineComponent({
transform: scale(1);
}
}
.dialog-enter-active,
.dialog-leave-active {
transition: opacity var(--slow) var(--transition);
::v-deep > *:not(.v-overlay) {
transform: translateY(0px);
transition: transform var(--slow) var(--transition-in);
}
}
.dialog-enter,
.dialog-leave-to {
opacity: 0;
::v-deep > *:not(.v-overlay) {
transform: translateY(50px);
transition: transform var(--slow) var(--transition-out);
}
}
</style>

View File

@@ -66,7 +66,8 @@ A wrapper for list items that formats children nicely. Can be used on its own or
| `dense` | Removes some padding to make the individual list item shorter | `false` |
| `lines` | Sets if the list item will support `1`, `2`, or `3` lines of content | `null` |
| `to` | Render as vue router-link with to link | `null` |
| `disabled` | Disable the list item | `false` |
| `disabled` | Disable the list item | `false` |
| `active` | Enable the list item's active state | `false` |
## Events

View File

@@ -5,6 +5,7 @@
class="v-list-item"
:to="to"
:class="{
active,
dense,
link: isClickable,
'three-line': lines === 3,
@@ -40,6 +41,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
active: {
type: Boolean,
default: false,
},
},
setup(props, { listeners }) {
const component = computed<string>(() => (props.to ? 'router-link' : 'li'));

View File

@@ -0,0 +1,4 @@
import VModal from './v-modal.vue';
export { VModal };
export default VModal;

View File

@@ -0,0 +1,73 @@
# Modal
A modal is basically an elaborate pre-configured dialog. It supports an optional left sidebar that allows for easier tab usage.
## Usage
```html
<v-modal title="My Modal" v-modal="active">
Hello, world!
</v-modal>
```
```html
<v-modal title="My Modal">
<template #activator="{ on }">
<v-button @click="on">Open modal</v-button>
</template>
Hello, world!
</v-modal>
```
```html
<v-modal title="My Modal" v-model="active">
<template #activator="{ on }">
<v-button @click="on">Open modal</v-button>
</template>
<template #sidebar>
<v-tabs vertical>
<v-tab>Hello</v-tab>
<v-tab>Page 2</v-tab>
<v-tab>Page 3</v-tab>
</v-tabs>
</template>
<v-tabs-items>
<v-tab-item>Hello, world!</v-tab-item>
<v-tab-item>I'm page 2!</v-tab-item>
<v-tab-item>I'm page 3!</v-tab-item>
</v-tabs-items>
<template #footer="{ close }">
<v-button @click="close">Close modal</v-button>
</template>
</v-modal>
```
## Props
| Prop | Description | Default |
|--------------|-----------------------------------------------------------------|---------|
| `title`* | Title for the modal | |
| `subtitle` | Optional subtitle for the modal | |
| `active` | If the modal is active. Used in `v-model` | `false` |
| `persistent` | Prevent the user from exiting the modal by clicking the overlay | `false` |
## Events
| Event | Description | Value |
|----------|--------------------------|-----------|
| `toggle` | Sync the `v-model` value | `boolean` |
## Slots
| Slot | Description | Data |
|-------------|--------------------------------------------------------|-------------------------|
| _default_ | Modal content | |
| `activator` | Element to enable the modal | `{ on: () => void }` |
| `sidebar` | Sidebar content for the modal. Often used for `v-tabs` | |
| `footer` | Footer content. Often used for action buttons | `{ close: () => void }` |
## CSS Variables
n/a

View File

@@ -0,0 +1,84 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import readme from './readme.md';
import { defineComponent, ref } from '@vue/composition-api';
export default {
title: 'Components / Modal',
parameters: {
notes: readme,
},
decorators: [withPadding],
};
export const basic = () =>
defineComponent({
setup() {
const active = ref(false);
return { active };
},
template: `
<div>
<v-modal
v-model="active"
title="Creating New Collection"
subtitle="called Customers"
>
<template #activator="{ on }">
<v-button @click="on">Enable modal</v-button>
</template>
<p>Hello world!</p>
<template #footer="{ close }">
<v-button @click="close">Close modal</v-button>
</template>
</v-modal>
<portal-target name="dialog-outlet" />
</div>
`,
});
export const withNav = () =>
defineComponent({
setup() {
const active = ref(false);
const current = ref(['hello']);
return { active, current };
},
template: `
<div>
<v-modal
v-model="active"
title="Creating New Collection"
subtitle="called Customers"
>
<template #activator="{ on }">
<v-button @click="on">Enable modal</v-button>
</template>
<template #sidebar>
<v-tabs v-model="current" vertical>
<v-tab value="hello">Hello</v-tab>
<v-tab value="introduce">Modal</v-tab>
</v-tabs>
</template>
<v-tabs-items v-model="current">
<v-tab-item value="hello">
<p>Hello world!</p>
</v-tab-item>
<v-tab-item value="introduce">
<p>I'm a modal with tabs</p>
</v-tab-item>
</v-tabs-items>
<template #footer="{ close }">
<v-button @click="close">Close modal</v-button>
</template>
</v-modal>
<portal-target name="dialog-outlet" />
</div>
`,
});

View File

@@ -0,0 +1,24 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VModal from './v-modal.vue';
import VDialog from '@/components/v-dialog/';
import VIcon from '@/components/v-icon/';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-dialog', VDialog);
localVue.component('v-icon', VIcon);
describe('Components / Modal', () => {
it('Renders', () => {
const component = shallowMount(VModal, {
localVue,
propsData: {
title: 'My Modal',
},
});
expect(component.isVueInstance()).toBe(true);
});
});

View File

@@ -0,0 +1,187 @@
<template>
<v-dialog :active="active" @toggle="$emit('toggle', $event)" :persistent="persistent">
<template #activator="{ on }">
<slot name="activator" v-bind="{ on }" />
</template>
<article class="v-modal">
<header class="header">
<v-icon class="menu-toggle" name="menu" @click="sidebarActive = !sidebarActive" />
<h2 class="title">{{ title }}</h2>
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
<div class="spacer" />
<v-icon name="" />
</header>
<div class="content">
<v-overlay
v-if="$slots.sidebar"
absolute
:active="sidebarActive"
@click="sidebarActive = false"
/>
<nav
v-if="$slots.sidebar"
class="sidebar"
:class="{ active: sidebarActive }"
@click="sidebarActive = false"
>
<slot name="sidebar" />
</nav>
<main class="main">
<slot />
</main>
</div>
<footer class="footer" v-if="$slots.footer">
<slot name="footer" v-bind="{ close: () => $emit('toggle', false) }" />
</footer>
</article>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
export default defineComponent({
model: {
prop: 'active',
event: 'toggle',
},
props: {
title: {
type: String,
required: true,
},
subtitle: {
type: String,
default: null,
},
active: {
type: Boolean,
default: true,
},
persistent: {
type: Boolean,
default: false,
},
},
setup() {
const sidebarActive = ref(false);
return { sidebarActive };
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.v-modal {
display: flex;
flex-direction: column;
width: calc(100% - 16px);
max-width: 916px;
height: calc(100% - 16px);
max-height: 760px;
background-color: var(--background-color);
border-radius: 4px;
.spacer {
flex-grow: 1;
}
.header {
display: flex;
align-items: center;
height: 60px;
padding: 0 16px;
border-bottom: 2px solid var(--background-color-alt);
.title {
margin-right: 12px;
font-size: 16px;
}
.subtitle {
color: var(--foreground-color-secondary);
}
.menu-toggle {
margin-right: 8px;
@include breakpoint(medium) {
display: none;
}
}
@include breakpoint(medium) {
padding: 0 24px;
}
}
.content {
position: relative;
display: flex;
flex-grow: 1;
overflow: hidden;
.sidebar {
position: absolute;
top: 0;
left: 0;
flex-basis: 220px;
flex-shrink: 0;
width: 220px;
height: 100%;
background-color: var(--background-color-alt);
transform: translateX(-100%);
transition: transform var(--slow) var(--transition-out);
&.active {
transform: translateX(0);
transition-timing-function: var(--transition-in);
}
@include breakpoint(medium) {
position: relative;
transform: translateX(0);
}
}
.v-overlay {
--v-overlay-z-index: none;
@include breakpoint(medium) {
display: none;
}
}
.main {
flex-grow: 1;
padding: 8px 16px;
overflow: auto;
@include breakpoint(medium) {
padding: 24px;
}
}
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
height: 60px;
padding: 0 16px;
border-top: 2px solid var(--background-color-alt);
@include breakpoint(medium) {
padding: 0 24px;
}
}
@include breakpoint(medium) {
width: calc(100% - 64px);
height: calc(100% - 64px);
}
}
</style>

View File

@@ -1,11 +1,20 @@
<template>
<div class="v-tab" :class="{ active, disabled }" @click="onClick">
<v-list-item
v-if="vertical"
class="v-tab vertical"
:active="active"
:disabled="disabled"
@click="onClick"
>
<slot v-bind="{ active, toggle }" />
</v-list-item>
<div v-else class="v-tab horizontal" :class="{ active, disabled }" @click="onClick">
<slot v-bind="{ active, toggle }" />
</div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { defineComponent, inject, ref } from '@vue/composition-api';
import { useGroupable } from '@/compositions/groupable';
export default defineComponent({
@@ -20,8 +29,10 @@ export default defineComponent({
},
},
setup(props) {
const { active, toggle } = useGroupable(props.value);
return { active, toggle, onClick };
const { active, toggle } = useGroupable(props.value, 'v-tabs');
const vertical = inject('v-tabs-vertical', ref(false));
return { active, toggle, onClick, vertical };
function onClick() {
if (props.disabled === false) toggle();
@@ -31,7 +42,7 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
.v-tab {
.v-tab.horizontal {
--v-tab-color: var(--input-foreground-color);
--v-tab-background-color: var(--input-background-color);
--v-tab-color-active: var(--input-foreground-color);

View File

@@ -1,12 +1,15 @@
<template>
<div class="v-tabs" :class="{ vertical }">
<v-list class="v-tabs vertical alt-colors" v-if="vertical" nav>
<slot />
<div class="slider" :style="slideStyle"></div>
</v-list>
<div v-else class="v-tabs horizontal">
<slot />
<div class="slider" :style="slideStyle" />
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, toRefs, computed } from '@vue/composition-api';
import { defineComponent, PropType, toRefs, computed, provide, ref } from '@vue/composition-api';
import { useGroupableParent } from '@/compositions/groupable';
export default defineComponent({
@@ -21,20 +24,20 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const { value: selection } = toRefs(props);
const { value: selection, vertical } = toRefs(props);
const options = toRefs({
multiple: false,
max: -1,
mandatory: true,
});
provide('v-tabs-vertical', vertical);
const { items } = useGroupableParent(
{
selection: selection,
onSelectionChange: update,
},
options
{
multiple: ref(false),
mandatory: ref(true),
},
'v-tabs'
);
const slideStyle = computed(() => {
@@ -50,12 +53,12 @@ export default defineComponent({
emit('input', newSelection);
}
return { update, slideStyle };
return { update, slideStyle, items };
},
});
</script>
<style lang="scss" scoped>
.v-tabs {
.v-tabs.horizontal {
--v-tabs-underline-color: var(--foreground-color);
position: relative;
@@ -83,20 +86,5 @@ export default defineComponent({
transition: var(--medium) cubic-bezier(0.66, 0, 0.33, 1);
transition-property: left, top;
}
&.vertical {
flex-direction: column;
::v-deep .v-tab {
justify-content: flex-start;
}
.slider {
top: calc(100% / var(--_v-tabs-items) * var(--_v-tabs-selected));
left: 0;
width: 2px;
height: calc(100% / var(--_v-tabs-items));
}
}
}
</style>

View File

@@ -11,9 +11,9 @@ type GroupableInstance = {
* Used to make child item part of the group context. Needs to be used in a component that is a child
* of a component that has the `useGroupableParent` composition enabled
*/
export function useGroupable(value?: string | number) {
export function useGroupable(value?: string | number, group = 'item-group') {
// Injects the registration / toggle functions from the parent scope
const parentFunctions = inject('item-group', null);
const parentFunctions = inject(group, null);
if (isEmpty(parentFunctions)) {
return {
@@ -65,7 +65,8 @@ type GroupableParentOptions = {
*/
export function useGroupableParent(
state: GroupableParentState = {},
options: GroupableParentOptions = {}
options: GroupableParentOptions = {},
group = 'item-group'
) {
// References to the active state and value of the individual child items
const items = ref<GroupableInstance[]>([]);
@@ -95,7 +96,7 @@ export function useGroupableParent(
// Provide the needed functions to all children groupable components. Note: nested item groups
// will override the item-group namespace, making nested item groups possible.
provide('item-group', { register, unregister, toggle });
provide(group, { register, unregister, toggle });
// Whenever the value of the selection changes, we have to update all the children's internal
// states. If not, you can have an activated item that's not actually active.

View File

@@ -8,7 +8,7 @@ the functionality of the `v-item-group` base component, and other groupable comp
Use the `useGroupableParent` function in a parent component that will contain one or more components
in it's slots (deeply nested or not) that use the `useGroupable` compositions.
### `useGroupableParent(state: GroupableParentState, options: GroupableParentOptions): void`
### `useGroupableParent(state: GroupableParentState, options: GroupableParentOptions, group: string): void`
The `useGroupableParent` composition accepts two paremeters: state and options.
State includes a `selection` key that can be used to pass an array of selected items, so you can
@@ -42,7 +42,10 @@ export default defineComponent({
});
```
### `useGroupable(value: string | number): { active: Ref<boolean>; toggle: () => void; }`
The optional group parameter allows you to control to what group this parent is registered. This
can be useful when you have complexly nested groups.
### `useGroupable(value: string | number | undefined, group: string): { active: Ref<boolean>; toggle: () => void; }`
Registers this component as a child of the first parent component that uses the `useGroupableParent`
component.
@@ -61,3 +64,6 @@ export default defineComponent({
}
});
```
The optional group parameter allows you to control to what group this child is registered. This
can be useful when you have complexly nested groups.