Migrate existing (finished) base components

This commit is contained in:
rijkvanzanten
2020-02-05 15:11:40 -05:00
parent eb011906e7
commit 55e56e30ec
77 changed files with 8530 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,26 @@
# Avatar
```html
<v-avatar>RVZ</v-avatar>
<v-avatar>
<img src="..." />
</v-avatar>
<v-avatar>
<v-icon name="person">
</v-avatar>
```
## Sizes / Colors
The avatar component supports multiple sizes and colors. The color prop accepts any valid CSS color. CSS variable names can be passed as well.
| Prop Name | Size in PX |
|----------------|------------|
| `x-small` | 32 |
| `small` | 40 |
| None (default) | 48 |
| `large` | 56 |
| `x-large` | 64 |

View File

@@ -0,0 +1,174 @@
import {
withKnobs,
text,
boolean,
number,
color,
optionsKnob as options
} from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VAvatar from './v-avatar.vue';
import VIcon from '../v-icon/';
import markdown from './v-avatar.readme.md';
Vue.component('v-avatar', VAvatar);
Vue.component('v-icon', VIcon);
export default {
title: 'Components / Avatar',
component: VAvatar,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const withText = () => ({
props: {
text: {
default: text('Text', 'RVZ')
},
tile: {
default: boolean('Tile', false)
},
color: {
default: color('Color', '#009688')
},
size: {
default: options(
'Size',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
}
)
},
customSize: {
default: number('Size (in px)', 0)
}
},
template: `
<v-avatar
:x-small="size === 'xSmall'"
:small="size === 'small'"
:large="size === 'large'"
:x-large="size === 'xLarge'"
:tile="tile"
:color="color"
:size="customSize"
>{{ text }}</v-avatar>`
});
export const withImage = () => ({
props: {
tile: {
default: boolean('Tile', false)
},
color: {
default: color('Color', '#009688')
},
size: {
default: options(
'Size',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
}
)
},
customSize: {
default: number('Size (in px)', 0)
}
},
template: `
<v-avatar
:x-small="size === 'xSmall'"
:small="size === 'small'"
:large="size === 'large'"
:x-large="size === 'xLarge'"
:tile="tile"
:color="color"
:size="customSize"
>
<img src="https://randomuser.me/api/portraits/men/97.jpg" />
</v-avatar>`
});
export const withIcon = () => ({
props: {
tile: {
default: boolean('Tile', false)
},
color: {
default: color('Color', '#009688')
},
size: {
default: options(
'Size',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
}
)
},
customSize: {
default: number('Size (in px)', 0)
}
},
template: `
<v-avatar
:x-small="size === 'xSmall'"
:small="size === 'small'"
:large="size === 'large'"
:x-large="size === 'xLarge'"
:tile="tile"
:color="color"
:size="customSize"
>
<v-icon name="person" />
</v-avatar>`
});
export const sizes = () => ({
template: `
<div style="display: flex; justify-content: space-around; align-items: center;">
<v-avatar x-small>
<img src="https://randomuser.me/api/portraits/men/97.jpg" />
</v-avatar>
<v-avatar small>
<img src="https://randomuser.me/api/portraits/men/97.jpg" />
</v-avatar>
<v-avatar>
<img src="https://randomuser.me/api/portraits/men/97.jpg" />
</v-avatar>
<v-avatar large>
<img src="https://randomuser.me/api/portraits/men/97.jpg" />
</v-avatar>
<v-avatar x-large>
<img src="https://randomuser.me/api/portraits/men/97.jpg" />
</v-avatar>
</div>
`
});

View File

@@ -0,0 +1,78 @@
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VAvatar from './v-avatar.vue';
describe('Avatar', () => {
let component: Wrapper<Vue>;
beforeEach(() => (component = mount(VAvatar, { localVue })));
it('Sets the tile class if tile prop is passed', async () => {
component.setProps({ tile: true });
await component.vm.$nextTick();
expect(component.classes()).toContain('tile');
});
it('Sets the correct custom color', async () => {
component.setProps({ color: '--red' });
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-avatar-color']).toEqual('var(--red)');
});
describe('Sizes', () => {
test('Extra Small', () => {
component.setProps({
xSmall: true,
small: false,
large: false,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('x-small'));
});
test('Small', () => {
component.setProps({
xSmall: false,
small: true,
large: false,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('small'));
});
test('Large', () => {
component.setProps({
xSmall: false,
small: false,
large: true,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('large'));
});
test('Extra Large', () => {
component.setProps({
xSmall: false,
small: false,
large: false,
xLarge: true
});
component.vm.$nextTick(() => expect(component.classes()).toContain('x-large'));
});
it('Sets the correct custom size', () => {
const component = mount(VAvatar, {
localVue,
propsData: {
size: 128
}
});
expect((component.vm as any).styles['--_v-avatar-size']).toBe('128px');
});
});
});

View File

@@ -0,0 +1,118 @@
<template>
<div class="v-avatar" :style="styles" :class="[{ tile }, sizeClass]">
<slot />
</div>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '../../utils/parse-css-var';
export default createComponent({
props: {
color: {
type: String,
default: '--teal'
},
size: {
type: Number,
default: null
},
tile: {
type: Boolean,
default: false
},
xSmall: {
type: Boolean,
default: false
},
small: {
type: Boolean,
default: false
},
large: {
type: Boolean,
default: false
},
xLarge: {
type: Boolean,
default: false
}
},
setup(props) {
type Styles = {
'--_v-avatar-color': string;
'--_v-avatar-size'?: string;
};
const styles = computed(() => {
const styles: Styles = {
'--_v-avatar-color': parseCSSVar(props.color)
};
if (props.size) {
styles['--_v-avatar-size'] = props.size + 'px';
}
return styles;
});
const sizeClass = computed<string | null>(() => {
if (props.xSmall) return 'x-small';
if (props.small) return 'small';
if (props.large) return 'large';
if (props.xLarge) return 'x-large';
return null;
});
return { styles, sizeClass };
}
});
</script>
<style lang="scss" scoped>
.v-avatar {
--_v-avatar-size: 48px;
background-color: var(--_v-avatar-color);
display: flex;
justify-content: center;
align-items: center;
position: relative;
width: var(--_v-avatar-size);
height: var(--_v-avatar-size);
border-radius: 50%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--white);
&.tile {
border-radius: 0;
}
&.x-small {
--_v-avatar-size: 24px;
}
&.small {
--_v-avatar-size: 36px;
}
&.large {
--_v-avatar-size: 56px;
}
&.x-large {
--_v-avatar-size: 64px;
}
::v-deep {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,86 @@
# Button
```html
<v-button>Click me!</v-button>
```
## Sizes
The button component supports the following sizes through the use of props:
* x-small
* small
* (default)
* large
* x-large
Alternatively, you can force the font-size through the `size` prop:
```html
<v-button :size="64">Click me!</v-button>
```
## Colors
You can set the color, background color, hover color, and background hover color with the `color`, `background-color`, `hover-color`, and `hover-background-color` props respectively:
```html
<v-button
color="--red"
background-color="--red-50"
hover-color="--white"
hover-background-color="--red"
>
Click me
</v-button>
```
## Events
The only event that can be added to the button is the `click` event:
```html
<v-button @click="sayHi">Hello!</v-button>
```
## Loading
The button has a loading state that can be enabled with the `loading` prop. By default, the button will render a `v-spinner`. You can override what's being shown during the loading state by using the `#loading` slot:
```html
<v-button>
<template #loading>
... Almost done ...
</template>
</v-button>
```
The loading slot is rendered _on top_ of the content that was there before. Make sure that your loading content doesn't exceed the size of the default state content. This restriction is put in place to prevent jumps when going from and to the loading state.
## Props
| Prop | Description | Default |
|--------------------------|---------------------------------------------------------------------------|-------------------------------------------|
| `block` | Enable ull width (display block) | `false` |
| `icon` | Remove padding / min-width. Meant to be used with just an icon as content | `false` |
| `outlined` | No background | `false` |
| `rounded` | Enable rounded corners | `false` |
| `color` | Text / icon color | `--button-primary-text-color` |
| `hover-color` | Text / icon color on hover | `--button-primary-text-color` |
| `background-color` | Button color | `--button-primary-background-color` |
| `hover-background-color` | Button color on hover | `--button-primary-background-color-hover` |
| `type` | HTML `type` attribute | `button` |
| `disabled` | Disabled state | `false` |
| `loading` | Loading state | `false` |
| `width` | Width in px | -- |
| `size` | Size of the text in the button in px | -- |
| `x-small` | Render extra small | `false` |
| `small` | Render small | `false` |
| `large` | Render large | `false` |
| `x-large` | Render extra large | `false` |
## Slots
| Slot | Description |
|-----------|----------------------------------------------|
| `loading` | Content that's rendered during loading state |

View File

@@ -0,0 +1,279 @@
import { withKnobs, text, boolean, number, optionsKnob as options } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VButton from './v-button.vue';
import VIcon from '../v-icon/';
import markdown from './v-button.readme.md';
Vue.component('v-button', VButton);
Vue.component('v-icon', VIcon);
export default {
title: 'Components / Button',
component: VButton,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const withText = () => ({
methods: { onClick: action('click') },
props: {
text: {
default: text('Text in button', 'Click me')
},
block: {
default: boolean('Block', false, 'Button')
},
rounded: {
default: boolean('Rounded', false, 'Button')
},
icon: {
default: boolean('Icon mode', false, 'Button')
},
outlined: {
default: boolean('Outlined', false, 'Button')
},
type: {
default: text('Type attribute', 'button', 'Button')
},
loading: {
default: boolean('Loading', false, 'Button')
},
width: {
default: number('Width', 0, {}, 'Button')
},
size: {
default: options(
'Size',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
},
'Button'
)
},
disabled: {
default: boolean('Disabled', false, 'Button')
},
color: {
default: text('Color', '--button-primary-text-color', 'Colors')
},
backgroundColor: {
default: text('Background Color', '--button-primary-background-color', 'Colors')
},
hoverColor: {
default: text('Color (hover)', '--white', 'Colors')
},
hoverBackgroundColor: {
default: text('Background Color (hover)', '--black', 'Colors')
}
},
template: `
<v-button
:block="block"
:rounded="rounded"
:outlined="outlined"
:icon="icon"
:color="color"
:background-color="backgroundColor"
:hover-color="hoverColor"
:hover-background-color="hoverBackgroundColor"
:type="type"
:disabled="disabled"
:loading="loading"
:width="width"
:x-small="size === 'xSmall'"
:small="size === 'small'"
:large="size === 'large'"
:x-large="size === 'xLarge'"
@click="onClick"
>
{{ text }}
</v-button>
`
});
export const withIcon = () => ({
methods: { onClick: action('click') },
props: {
iconName: {
default: text('Material Icon', 'add')
},
block: {
default: boolean('Block', false, 'Button')
},
rounded: {
default: boolean('Rounded', true, 'Button')
},
icon: {
default: boolean('Icon mode', true, 'Button')
},
outlined: {
default: boolean('Outlined', false, 'Button')
},
type: {
default: text('Type attribute', 'button', 'Button')
},
loading: {
default: boolean('Loading', false, 'Button')
},
width: {
default: number('Width', 0, {}, 'Button')
},
size: {
default: options(
'Size',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
},
'Button'
)
},
iconSize: {
default: options(
'Size (Icon)',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
},
'Button'
)
},
disabled: {
default: boolean('Disabled', false, 'Button')
},
color: {
default: text('Color', '--button-primary-text-color', 'Colors')
},
backgroundColor: {
default: text('Background Color', '--button-primary-background-color', 'Colors')
},
hoverColor: {
default: text('Color (hover)', '--white', 'Colors')
},
hoverBackgroundColor: {
default: text('Background Color (hover)', '--black', 'Colors')
}
},
template: `
<v-button
:block="block"
:rounded="rounded"
:outlined="outlined"
:icon="icon"
:color="color"
:background-color="backgroundColor"
:hover-color="hoverColor"
:hover-background-color="hoverBackgroundColor"
:type="type"
:disabled="disabled"
:loading="loading"
:width="width"
:x-small="size === 'xSmall'"
:small="size === 'small'"
:large="size === 'large'"
:x-large="size === 'xLarge'"
@click="onClick"
>
<v-icon
:name="iconName"
:x-small="iconSize === 'xSmall'"
:small="iconSize === 'small'"
:large="iconSize === 'large'"
:x-large="iconSize === 'xLarge'"
/>
</v-button>
`
});
export const sizes = () => `
<div>
<v-button x-small>Extra small</v-button>
<v-button small>Small</v-button>
<v-button>Default</v-button>
<v-button large>Large</v-button>
<v-button x-large>Extra Large</v-button>
</div>
`;
export const colors = () => `
<div>
<v-button
color="--red"
background-color="--red-50"
hover-color="--white"
hover-background-color="--red"
>
Delete
</v-button>
<v-button
color="--white"
background-color="--green"
hover-background-color="--green-800"
>
Save
</v-button>
<v-button
color="--white"
background-color="--amber"
hover-background-color="--amber-800"
>
Warn
</v-button>
<v-button
color="--blue-grey-800"
background-color="--blue-grey-50"
hover-color="--red"
hover-background-color="--white"
>
Hover
</v-button>
<v-button
color="--blue-grey-800"
background-color="transparent"
hover-color="--black"
hover-background-color="--blue-grey-100"
>
Transparent
</v-button>
</div>
`;
export const customLoading = () => ({
props: {
loading: {
default: boolean('Loading', true)
}
},
template: `
<v-button :loading="loading">
Hello, World!
<template #loading>
..Loading..
</template>
</v-button>`
});

View File

@@ -0,0 +1,157 @@
import { mount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-spinner', VSpinner);
import VButton from './v-button.vue';
import VSpinner from '../v-spinner/';
describe('Button', () => {
it('Renders the provided markup in the default slow', () => {
const component = mount(VButton, {
localVue,
slots: {
default: 'Click me'
}
});
expect(component.text()).toContain('Click me');
});
it('Adds the outline class for outline buttons', () => {
const component = mount(VButton, {
localVue,
propsData: {
outlined: true
}
});
expect(component.classes()).toContain('outlined');
});
it('Adds the block class for block buttons', () => {
const component = mount(VButton, {
localVue,
propsData: {
block: true
}
});
expect(component.classes()).toContain('block');
});
it('Adds the rounded class for rounded buttons', () => {
const component = mount(VButton, {
localVue,
propsData: {
rounded: true
}
});
expect(component.classes()).toContain('rounded');
});
it('Adds the icon class for icon buttons', () => {
const component = mount(VButton, {
localVue,
propsData: {
icon: true
}
});
expect(component.classes()).toContain('icon');
});
it('Adds the loading class for loading buttons', () => {
const component = mount(VButton, {
localVue,
propsData: {
loading: true
}
});
expect(component.classes()).toContain('loading');
});
it('Sets the correct CSS variables for custom colors', () => {
const component = mount(VButton, {
localVue,
propsData: {
color: '--red',
hoverColor: '--blue',
backgroundColor: '--green',
hoverBackgroundColor: '--yellow'
}
});
expect((component.vm as any).styles['--_v-button-color']).toBe('var(--red)');
expect((component.vm as any).styles['--_v-button-hover-color']).toBe('var(--blue)');
expect((component.vm as any).styles['--_v-button-background-color']).toBe('var(--green)');
expect((component.vm as any).styles['--_v-button-hover-background-color']).toBe(
'var(--yellow)'
);
});
describe('Sizes', () => {
const component = mount(VButton, {
localVue,
propsData: {
color: '--blue-grey',
name: 'person'
}
});
test('Extra Small', () => {
component.setProps({
xSmall: true,
small: false,
large: false,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('x-small'));
});
test('Small', () => {
component.setProps({
xSmall: false,
small: true,
large: false,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('small'));
});
test('Large', () => {
component.setProps({
xSmall: false,
small: false,
large: true,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('large'));
});
test('Extra Large', () => {
component.setProps({
xSmall: false,
small: false,
large: false,
xLarge: true
});
component.vm.$nextTick(() => expect(component.classes()).toContain('x-large'));
});
it('Sets the correct custom width', () => {
const component = mount(VButton, {
localVue,
propsData: {
width: 56
}
});
expect((component.vm as any).styles.width).toBe('56px');
});
});
});

View File

@@ -0,0 +1,236 @@
<template>
<button
class="v-button"
:class="[sizeClass, { block, rounded, icon, outlined, loading }]"
:type="type"
:style="styles"
:disabled="disabled"
@click="!loading ? $emit('click') : null"
>
<span class="content" :class="{ invisible: loading }"><slot /></span>
<div class="spinner">
<slot v-if="loading" name="loading">
<v-spinner :x-small="xSmall" :small="small" />
</slot>
</div>
</button>
</template>
<script lang="ts">
import { createComponent, reactive, computed, Ref } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
export default createComponent({
props: {
block: {
type: Boolean,
default: false
},
rounded: {
type: Boolean,
default: false
},
outlined: {
type: Boolean,
default: false
},
icon: {
type: Boolean,
default: false
},
color: {
type: String,
default: '--button-primary-text-color'
},
backgroundColor: {
type: String,
default: '--button-primary-background-color'
},
hoverColor: {
type: String,
default: '--button-primary-text-color'
},
hoverBackgroundColor: {
type: String,
default: '--button-primary-background-color-hover'
},
type: {
type: String,
default: 'button'
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
width: {
type: Number,
default: null
},
xSmall: {
type: Boolean,
default: false
},
small: {
type: Boolean,
default: false
},
large: {
type: Boolean,
default: false
},
xLarge: {
type: Boolean,
default: false
}
},
setup(props) {
interface Styles {
'--_v-button-color': string;
'--_v-button-background-color': string;
'--_v-button-hover-color': string;
'--_v-button-hover-background-color': string;
width?: string;
}
const styles = computed<Styles>(() => {
let styles: Styles = {
'--_v-button-color': parseCSSVar(props.color),
'--_v-button-background-color': parseCSSVar(props.backgroundColor),
'--_v-button-hover-color': parseCSSVar(props.hoverColor),
'--_v-button-hover-background-color': parseCSSVar(props.hoverBackgroundColor)
};
if (props.width && +props.width > 0) {
styles.width = props.width + 'px';
}
return styles;
});
const sizeClass = computed<string | null>(() => {
if (props.xSmall) return 'x-small';
if (props.small) return 'small';
if (props.large) return 'large';
if (props.xLarge) return 'x-large';
return null;
});
return { styles, sizeClass };
}
});
</script>
<style lang="scss" scoped>
.v-button {
--_v-button-height: 44px;
color: var(--_v-button-color);
background-color: var(--_v-button-background-color);
border-radius: var(--border-radius);
font-weight: var(--weight-bold);
cursor: pointer;
border: var(--input-border-width) solid var(--_v-button-background-color);
font-size: 14px;
padding: 0 19px;
min-width: 78px;
height: var(--_v-button-height);
transition: var(--fast) var(--transition);
transition-property: background-color border;
position: relative;
&:focus {
outline: 0;
}
&:not(.loading):not(:disabled):hover {
color: var(--_v-button-hover-color);
background-color: var(--_v-button-hover-background-color);
border: var(--input-border-width) solid var(--_v-button-hover-background-color);
}
&.block {
display: block;
min-width: 100%;
}
&.rounded {
border-radius: calc(var(--button-height) / 2);
}
&.outlined {
background-color: transparent;
}
&:disabled {
background-color: var(--button-primary-background-color-disabled);
border: var(--input-border-width) solid var(--button-primary-background-color-disabled);
color: var(--button-primary-text-color-disabled);
cursor: not-allowed;
}
&.x-small {
--_v-button-height: 28px;
font-size: 12px;
padding: 0 12px;
min-width: 48px;
}
&.small {
--_v-button-height: 36px;
font-size: 14px;
padding: 0 16px;
min-width: 64px;
}
&.large {
--_v-button-height: var(--button-height);
font-size: var(--button-font-size);
padding: 0 23px;
min-width: 92px;
}
&.x-large {
--_v-button-height: 58px;
font-size: 18px;
padding: 0 32px;
min-width: 120px;
}
&.icon {
min-width: 0;
padding: 0;
width: var(--_v-button-height);
}
.content,
.spinner {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 100%;
}
.content {
position: relative;
top: -1;
&.invisible {
opacity: 0;
}
}
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
</style>

View File

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

View File

@@ -0,0 +1,92 @@
# Checkbox
## Basic usage
```html
<v-checkbox v-model="checked" label="Receive newsletter" />
```
## Colors
The checkbox component accepts any CSS color value, or variable name:
```html
<v-checkbox color="#abcabc" />
<v-checkbox color="rgba(125, 125, 198, 0.5)" />
<v-checkbox color="--red" />
<v-checkbox color="--input-border-color" />
```
## Boolean vs arrays
Just as with checkboxes, you can use `v-model` with both an array and a boolean:
```html
<template>
<v-checkbox v-model="withBoolean" />
<v-checkbox v-model="withArray" value="red" />
<v-checkbox v-model="withArray" value="blue" />
<v-checkbox v-model="withArray" value="green" />
</template>
<script>
export default {
data() {
return {
withBoolean: false,
withArray: ['red', 'green']
}
}
}
</script>
```
Keep in mind to pass the `value` prop with a unique value when using arrays in `v-model`.
## Indeterminate
The indeterminate state can be set with the `indeterminate` prop. We recommend using the `.sync` modifier with the indeterminate prop, so the checkbox can set change it too:
```html
<v-checkbox :indeterminate.sync="indeterminate">
<script>
export default {
data() {
indeterminate: true
}
}
</script>
```
If you can't, you should listen to the `update:indeterminate` event and respond to that:
```html
<v-checkbox indeterminate @update:indeterminate="setIndeterminate">
```
## Events
| Event | Description | Data |
|------------------------|----------------------------|----------------------------|
| `change` | New state for the checkbox | Boolean or array of values |
| `update:indeterminate` | New state for the checkbox | Boolean or array of values |
## Props
| Prop | Description | Default |
|-----------------|--------------------------------------------------------------------------------------------------------|-----------------------------------|
| `value` | Value for checkbox. Similar to value attr on checkbox type input in HTML | `--` |
| `inputValue` | Value that's used with `v-model`. Either boolean or array of values | `false` |
| `label` | Label for the checkbox | `--` |
| `color` | Color for the checked state of the checkbox. Either CSS var name (fe `--red`) or other valid CSS color | `--input-background-color-active` |
| `disabled` | Disable the checkbox | `false` |
| `indeterminate` | Show the indeterminate state | `false` |
## Slots
| Slot | Description |
|---------|------------------------------------------------------------------------------------------------|
| `label` | Allows custom markup and HTML to be rendered inside the label. Will override the `label` prop. |

View File

@@ -0,0 +1,120 @@
import {
withKnobs,
text,
boolean,
number,
optionsKnob as options,
color
} from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VCheckbox from '../v-checkbox';
import markdown from './v-checkbox.readme.md';
Vue.component('v-checkbox', VCheckbox);
export default {
title: 'Components / Checkbox',
component: VCheckbox,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const booleanState = () => ({
methods: {
onChange: action('change')
},
data() {
return {
checked: false
};
},
template: `
<div>
<v-checkbox v-model="checked" @change="onChange" />
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">{{checked}}</pre>
</div>
`
});
export const arrayState = () => ({
methods: {
onChange: action('change')
},
data() {
return {
options: ['html', 'css']
};
},
template: `
<div>
<v-checkbox v-model="options" value="html" @change="onChange" label="HTML" />
<v-checkbox v-model="options" value="css" @change="onChange" label="CSS" />
<v-checkbox v-model="options" value="js" @change="onChange" label="JS" />
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">{{options}}</pre>
</div>
`
});
export const disabled = () =>
`<div><v-checkbox label="Disabled" disabled /><v-checkbox :inputValue="true" label="Disabled" disabled /></div>`;
export const indeterminate = () => ({
data() {
return {
indeterminate: true,
value: null
};
},
template: `<div>
<v-checkbox label="Indeterminate" v-model="value" :indeterminate.sync="indeterminate" />
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">
indeterminate: {{indeterminate}}
value: {{value}}
</pre>
</div>`
});
export const colors = () => ({
methods: {
onChange: action('change')
},
data() {
return {
options: ['red', 'yellow', 'custom']
};
},
props: {
customColor: {
default: color('Custom color', '#4CAF50')
}
},
template: `
<div>
<v-checkbox v-model="options" value="red" @change="onChange" color="--red" label="Red" />
<v-checkbox v-model="options" value="blue" @change="onChange" color="--blue" label="Blue" />
<v-checkbox v-model="options" value="yellow" @change="onChange" color="--amber" label="Yellow" />
<v-checkbox v-model="options" value="custom" @change="onChange" :color="customColor" label="Custom..." />
</div>
`
});
export const htmlLabel = () => ({
methods: {
onChange: action('change')
},
data() {
return {
checked: true
};
},
template: `
<v-checkbox v-model="checked" @change="onChange">
<template #label>
Any <i>custom</i> markup in here
</template>
</v-checkbox>
`
});

View File

@@ -0,0 +1,149 @@
import { mount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VIcon from '../v-icon/';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-icon', VIcon);
import VCheckbox from './v-checkbox.vue';
describe('Checkbox', () => {
it('Renders passed label', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
label: 'Turn me on'
}
});
expect(component.find('span[class="label"]').text()).toContain('Turn me on');
});
it('Renders as checked when inputValue `true` is given', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
inputValue: true
}
});
expect((component.vm as any).isChecked).toBe(true);
});
it('Calculates check for array inputValue', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
value: 'red',
inputValue: ['red']
}
});
expect((component.vm as any).isChecked).toBe(true);
});
it('Emits true when state is false', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
inputValue: false
}
});
const button = component.find('button');
button.trigger('click');
expect(component.emitted().change[0][0]).toBe(true);
});
it('Disables the button when disabled prop is set', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
disabled: true
}
});
const button = component.find('button');
expect(Object.keys(button.attributes())).toContain('disabled');
});
it('Appends value to array', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
value: 'red',
inputValue: ['blue', 'green']
}
});
const button = component.find('button');
button.trigger('click');
expect(component.emitted().change[0][0]).toEqual(['blue', 'green', 'red']);
});
it('Removes value from array', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
value: 'red',
inputValue: ['blue', 'green', 'red']
}
});
const button = component.find('button');
button.trigger('click');
expect(component.emitted().change[0][0]).toEqual(['blue', 'green']);
});
it('Renders the correct icon for state', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
inputValue: false
}
});
expect((component.vm as any).icon).toBe('check_box_outline_blank');
component.setProps({ inputValue: true });
expect((component.vm as any).icon).toBe('check_box');
component.setProps({ indeterminate: true });
expect((component.vm as any).icon).toBe('indeterminate_check_box');
});
it('Renders the correct iconColor for state', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
inputValue: false,
color: '--red'
}
});
expect((component.vm as any).iconColor).toBe('--input-border-color');
component.setProps({ inputValue: true });
expect((component.vm as any).iconColor).toBe('--red');
});
it('Emits the update:indeterminate event when the checkbox is toggled when indeterminate', () => {
const component = mount(VCheckbox, {
localVue,
propsData: {
indeterminate: true
}
});
component.find('button').trigger('click');
expect(component.emitted('update:indeterminate')[0]).toEqual([false]);
});
});

View File

@@ -0,0 +1,125 @@
<template>
<button
class="v-checkbox"
@click="toggleInput"
type="button"
role="checkbox"
:aria-pressed="isChecked ? 'true' : 'false'"
:disabled="disabled"
>
<v-icon :name="icon" :color="iconColor" />
<span class="label">
<slot name="label">{{ label }}</slot>
</span>
</button>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
export default createComponent({
model: {
prop: 'inputValue',
event: 'change'
},
props: {
value: {
type: String,
default: null
},
inputValue: {
type: [Boolean, Array],
default: false
},
label: {
type: String,
default: null
},
color: {
type: String,
default: '--input-background-color-active'
},
disabled: {
type: Boolean,
default: false
},
indeterminate: {
type: Boolean,
default: false
}
},
setup(props, { emit }) {
const isChecked = computed<boolean>(() => {
if (props.inputValue instanceof Array) {
return props.inputValue.includes(props.value);
}
return props.inputValue === true;
});
const icon = computed<string>(() => {
if (props.indeterminate) return 'indeterminate_check_box';
return isChecked.value ? 'check_box' : 'check_box_outline_blank';
});
const iconColor = computed<string>(() => {
if (props.disabled) return '--input-background-color-disabled';
if (isChecked.value) return props.color;
return '--input-border-color';
});
return { isChecked, toggleInput, icon, iconColor };
function toggleInput(): void {
if (props.indeterminate) {
emit('update:indeterminate', false);
}
if (props.inputValue instanceof Array) {
let newValue = [...props.inputValue];
if (isChecked.value === false) {
newValue.push(props.value);
} else {
newValue.splice(newValue.indexOf(props.value), 1);
}
emit('change', newValue);
} else {
emit('change', !isChecked.value);
}
}
}
});
</script>
<style lang="scss" scoped>
.v-checkbox {
font-size: 0;
appearance: none;
background-color: transparent;
border-radius: 0;
border: none;
display: flex;
align-items: center;
text-align: left;
.label:not(:empty) {
font-size: var(--input-font-size);
margin-left: 8px;
vertical-align: middle;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&:disabled {
cursor: not-allowed;
.label {
color: var(--popover-text-color-disabled);
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,33 @@
# Hover (util)
```html
<v-hover v-slot="{ hover }">
<v-button :color="hover ? red : blue" />
</v-hover>
```
## Delays
You can control how long the hover state persists after the user leaves the element with the `close-delay` prop. Likewise, you can set how long it will take before the hover state is set with the `open-delay` prop:
```html
<v-hover v-slot="{ hover }" :open-delay="250" :close-delay="400">
<v-button :color="hover ? red : blue" />
</v-hover>
```
## Props
| Prop | Description | Default |
|---------------|----------------------------------------------|---------|
| `close-delay` | Delay (ms) before the hover state is removed | `0` |
| `open-delay` | Delay (ms) before the hover state is applied | `0` |
| `disabled` | Disables the hover state from happening | `false` |
| `tag` | What HTML tag to use for the wrapper | `div` |
## Events
n/a
## Slots
Only the default slot is available. The hover state is passed as scoped slot data

View File

@@ -0,0 +1,33 @@
import Vue from 'vue';
import markdown from './v-hover.readme.md';
import VIcon from '../v-icon/';
import VHover from './v-hover.vue';
Vue.component('v-hover', VHover);
Vue.component('v-icon', VIcon);
export default {
title: 'Components / Hover',
component: VHover,
parameters: {
notes: markdown
}
};
export const basic = () => `
<v-hover v-slot="{ hover }" tag="span">
<v-icon :color="hover ? '--red' : '--blue'" name="person" x-large />
</v-hover>
`;
export const customMarkup = () => `
<v-hover v-slot="{ hover }" tag="span">
<template v-if="hover">
<v-icon name="star" color="--amber" />
Hovering! 🎉🥳
</template>
<template v-else>
Hover me.
</template>
</v-hover>
`;

View File

@@ -0,0 +1,84 @@
import VueCompositionAPI from '@vue/composition-api';
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VHover from './v-hover.vue';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
function sleep(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
describe('Hover', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(VHover, { localVue });
jest.useFakeTimers();
});
it('Renders the correct element based on tag prop', async () => {
component.setProps({ tag: 'span' });
await component.vm.$nextTick();
expect(component.find('span').exists()).toBe(true);
component.setProps({ tag: 'section' });
await component.vm.$nextTick();
expect(component.find('section').exists()).toBe(true);
});
it('Keeps track of the hover state', async () => {
component.find('div').trigger('mouseenter');
jest.runAllTimers();
expect((component.vm as any).hover).toBe(true);
component.find('div').trigger('mouseleave');
jest.runAllTimers();
expect((component.vm as any).hover).toBe(false);
});
it('Adds delays to enter/leave based on props', async () => {
component.setProps({
openDelay: 300,
closeDelay: 600
});
component.find('div').trigger('mouseenter');
expect((component.vm as any).hover).toBe(false);
jest.advanceTimersByTime(150);
expect((component.vm as any).hover).toBe(false);
jest.advanceTimersByTime(200);
expect((component.vm as any).hover).toBe(true);
component.find('div').trigger('mouseleave');
jest.advanceTimersByTime(300);
expect((component.vm as any).hover).toBe(true);
jest.advanceTimersByTime(300);
expect((component.vm as any).hover).toBe(false);
});
it("Doesn't do anything if disabled prop is set", async () => {
component.setProps({ disabled: true });
expect((component.vm as any).hover).toBe(false);
component.find('div').trigger('mouseenter');
jest.runAllTimers();
expect((component.vm as any).hover).toBe(false);
component.find('div').trigger('mouseleave');
jest.runAllTimers();
expect((component.vm as any).hover).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
<template>
<component :is="tag" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
<slot v-bind="{ hover }" />
</component>
</template>
<script lang="ts">
import { createComponent, ref } from '@vue/composition-api';
export default createComponent({
props: {
closeDelay: {
type: Number,
default: 0
},
openDelay: {
type: Number,
default: 0
},
disabled: {
type: Boolean,
default: false
},
tag: {
type: String,
default: 'div'
}
},
setup(props) {
const hover = ref<boolean>(false);
return { hover, onMouseEnter, onMouseLeave };
function onMouseEnter() {
if (props.disabled === true) return;
setTimeout(() => {
hover.value = true;
}, props.openDelay);
}
function onMouseLeave() {
if (props.disabled === true) return;
setTimeout(() => {
hover.value = false;
}, props.closeDelay);
}
}
});
</script>

View File

@@ -0,0 +1,18 @@
<template functional>
<svg
viewBox="0 0 96 100"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="1.414"
>
<path
d="M3.153 79.825l42.917 19.73c1.287.593 2.573.593 3.86 0l42.906-19.73c1.787-.821 2.683-2.216 2.685-4.183V24.358a4.632 4.632 0 0 0-.081-.83v-.242c-.048-.2-.11-.396-.184-.587l-.069-.196a4.73 4.73 0 0 0-.369-.692l-.104-.149a4.668 4.668 0 0 0-.403-.485l-.219-.149a4.476 4.476 0 0 0-.507-.415l-.127-.092a4.558 4.558 0 0 0-.622-.346L49.919.445c-1.287-.593-2.574-.593-3.861 0L3.153 20.175a4.51 4.51 0 0 0-.623.346l-.126.092a4.476 4.476 0 0 0-.507.415l-.15.161c-.146.152-.28.313-.404.484l-.103.15c-.143.22-.266.45-.369.691l-.127.185a4.592 4.592 0 0 0-.184.587v.242a4.632 4.632 0 0 0-.081.83v51.284c0 1.964.891 3.358 2.674 4.183zm6.534-48.264l33.697 15.523v41.142L9.687 72.692V31.561zm42.917 56.654V47.084l33.697-15.523v41.142L52.604 88.215zm-4.61-78.55l31.9 14.693-31.9 14.694-31.899-14.694L47.994 9.665z"
/>
</svg>
</template>
<script lang="ts">
export default {};
</script>

View File

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

View File

@@ -0,0 +1,46 @@
# Icon
The icon component allows you to render any [Material Design Icons](https://material.io/icons). It also supports rendering of custom SVG based icons.
## Sizes / Colors
The icon component supports multiple sizes and colors. The color prop accepts any valid CSS color. CSS variable names can be passed as well.
| Prop Name | Size in PX |
|----------------|------------|
| `sup` | 8 |
| `x-small` | 12 |
| `small` | 18 |
| None (default) | 24 |
| `large` | 36 |
| `x-large` | 48 |
The `sup` size is meant to be used as superscript. For example the required state flag.
## Custom Size
If the default sizes don't give you the exact size you require, you can add the `size` prop with any
custom pixel value. Note: we recommend using one of the pre-defined sizes to ensure a consistent look
across the platform.
## Outline
You can render the outline variant of the Material Icon by setting the `outline` property.
## Click events
When you add a click event to the icon, the icon will automatically add a pointer cursor.
## Props
| Name | Description | Default |
|-----------|-------------------------------------------------------------------|----------------|
| `name`* | Name of the icon | -- |
| `color` | CSS color variable name (fe `--blue-grey`) or CSS color value | `currentColor` |
| `outline` | Use outline Material Icons. Note: only works for non-custom icons | `false` |
| `size` | Custom pixel size | `false` |
| `x-small` | Render the icon extra small | `false` |
| `small` | Render the icon small | `false` |
| `large` | Render the icon large | `false` |
| `x-large` | Render the icon extra large | `false` |
## Events
| Event | Description | Data |
|---------|----------------------|--------------|
| `click` | Standard click event | `MouseEvent` |

View File

@@ -0,0 +1,87 @@
import { withKnobs, text, boolean, number, optionsKnob as options } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VIcon from '../v-icon/';
import markdown from './v-icon.readme.md';
Vue.component('v-icon', VIcon);
export default {
title: 'Components / Icon',
component: VIcon,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const interactive = () => ({
methods: { onClick: action('click') },
props: {
name: {
default: text('Icon Name', 'person')
},
color: {
default: text('Color', '--blue-grey-800')
},
outline: {
default: boolean('Outline', false)
},
sup: {
default: boolean('Superscript', false)
},
size: {
default: options(
'Size',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
}
)
},
customSize: {
default: number('Size (in px)', 0)
}
},
template: `
<v-icon
:name="name"
:outline="outline"
:sup="sup"
:x-small="size === 'xSmall'"
:small="size === 'small'"
:large="size === 'large'"
:x-large="size === 'xLarge'"
:size="customSize"
/>
`
});
export const superscript = () => `<span>Title<v-icon name="star" color="--red" sup /></span>`;
export const sizesAndColors = () => `
<div>
<v-icon name="star" color="--light-blue" sup />
<v-icon name="accessibility_new" color="--red" x-small />
<v-icon name="warning" color="--purple" small />
<v-icon name="gesture" color="--blue" />
<v-icon name="security" color="--green" large />
<v-icon name="person" color="--orange" x-large />
</div>
`;
export const withClickEvent = () => ({
methods: {
click: action('click')
},
template: `
<v-icon name="star" @click="click" />
`
});

View File

@@ -0,0 +1,151 @@
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VIcon from './v-icon.vue';
describe('Icon', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(VIcon, { localVue, propsData: { name: 'person' } });
});
it('Renders the correct markup for a Material Icon', async () => {
component.setProps({
color: '--blue-grey',
name: 'person'
});
await component.vm.$nextTick();
expect(component.html()).toContain(
'<span class="v-icon" style="color: currentColor;"><i class="">person</i></span>'
);
});
it('Renders custom icons as inline <svg>', async () => {
component.setProps({
name: 'box'
});
await component.vm.$nextTick();
expect(component.contains('svg')).toBe(true);
});
it('Allows Hex/RGB/other CSS for color', async () => {
component.setProps({
color: 'papayawhip'
});
await component.vm.$nextTick();
expect((component.vm as any).colorStyle).toBe('papayawhip');
});
it('Passes custom size as px value', async () => {
component.setProps({
size: 120
});
await component.vm.$nextTick();
expect((component.vm as any).customSize).toBe('120px');
});
describe('Sizes', () => {
test('Superscript', async () => {
component.setProps({
sup: true,
xSmall: false,
small: false,
large: false,
xLarge: false
});
await component.vm.$nextTick();
expect(component.classes()).toContain('sup');
});
test('Extra Small', async () => {
component.setProps({
sup: false,
xSmall: true,
small: false,
large: false,
xLarge: false
});
await component.vm.$nextTick();
expect(component.classes()).toContain('x-small');
});
test('Small', async () => {
component.setProps({
sup: false,
xSmall: false,
small: true,
large: false,
xLarge: false
});
await component.vm.$nextTick();
expect(component.classes()).toContain('small');
});
test('Large', async () => {
component.setProps({
sup: false,
xSmall: false,
small: false,
large: true,
xLarge: false
});
await component.vm.$nextTick();
expect(component.classes()).toContain('large');
});
test('Extra Large', async () => {
component.setProps({
sup: false,
xSmall: false,
small: false,
large: false,
xLarge: true
});
await component.vm.$nextTick();
expect(component.classes()).toContain('x-large');
});
it('Uses the smallest size prop provided (sup)', async () => {
component.setProps({
sup: true,
xSmall: false,
small: true,
large: false,
xLarge: true
});
await component.vm.$nextTick();
expect(component.classes()).toContain('sup');
});
});
it('Adds the has-click class if a click event is passed', async () => {
const component = mount(VIcon, {
localVue,
propsData: {
name: 'person'
},
listeners: {
click: () => {}
}
});
expect(component.classes()).toContain('has-click');
});
it('Emits the click event on click of the icon', () => {
component.find('span').trigger('click');
expect(component.emitted('click')).toBeTruthy();
});
});

View File

@@ -0,0 +1,196 @@
<template>
<span
class="v-icon"
:class="[sizeClass, { 'has-click': hasClick }]"
:style="{ color: colorStyle, width: customSize, height: customSize }"
:role="hasClick ? 'button' : null"
@click="emitClick"
>
<component v-if="customIconName" :is="customIconName" />
<i v-else :style="{ fontSize: customSize }" :class="{ outline }">{{ name }}</i>
</span>
</template>
<script lang="ts">
import { createComponent, reactive, computed } from '@vue/composition-api';
import getSizeClass from '@/utils/get-size-class';
import parseCSSVar from '@/utils/parse-css-var';
import CustomIconBox from './custom-icons/box.vue';
const customIcons: string[] = ['box'];
export default createComponent({
components: { CustomIconBox },
props: {
name: {
type: String,
required: true
},
color: {
type: String,
default: 'currentColor'
},
outline: {
type: Boolean,
default: false
},
sup: {
type: Boolean,
default: false
},
size: {
type: Number,
default: null
},
xSmall: {
type: Boolean,
default: false
},
small: {
type: Boolean,
default: false
},
large: {
type: Boolean,
default: false
},
xLarge: {
type: Boolean,
default: false
}
},
setup(props, { emit, listeners }) {
const sizeClass = computed<string | null>(() => {
if (props.sup) return 'sup';
if (props.xSmall) return 'x-small';
if (props.small) return 'small';
if (props.large) return 'large';
if (props.xLarge) return 'x-large';
return null;
});
const customSize = computed<string | null>(() => {
if (props.size) return `${props.size}px`;
return null;
});
const colorStyle = computed<string>(() => {
return parseCSSVar(props.color);
});
const customIconName = computed<string | null>(() => {
if (customIcons.includes(props.name)) return `custom-icon-${props.name}`;
return null;
});
const hasClick = computed<boolean>(() => listeners.hasOwnProperty('click'));
return {
sizeClass,
colorStyle,
customIconName,
customSize,
hasClick,
emitClick
};
function emitClick(event: MouseEvent) {
emit('click', event);
}
}
});
</script>
<style lang="scss" scoped>
.v-icon {
position: relative;
display: inline-block;
font-size: 0;
width: 24px;
height: 24px;
vertical-align: middle;
&.sup {
width: 8px;
height: 8px;
vertical-align: 0px;
i {
font-size: 8px;
vertical-align: 5px;
}
}
&.x-small {
width: 12px;
height: 12px;
i {
font-size: 12px;
}
}
&.small {
width: 18px;
height: 18px;
i {
font-size: 18px;
}
}
// Default is 24x24
&.large {
width: 36px;
height: 36px;
i {
font-size: 36px;
}
}
&.x-large {
width: 48px;
height: 48px;
i {
font-size: 48px;
}
}
i {
font-size: 24px;
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
font-feature-settings: 'liga';
vertical-align: middle;
&.outline {
font-family: 'Material Icons Outline';
}
}
svg {
position: relative;
top: 2px;
left: 2px;
width: calc(100% - 4px); // the material icons all have a slight padding
height: calc(100% - 4px);
color: inherit;
fill: currentColor;
}
&.has-click {
cursor: pointer;
}
}
</style>

View File

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

View File

@@ -0,0 +1,45 @@
# Input
```html
<v-input v-model="value" />
```
## Attributes & Events
The HTML `<input>` element supports a huge amount of attributes and events. In order to support all of these, all props that aren't specially handled (see list below) will be passed on to the `<input>` element directly. This allows you to change anything you want on the input.
## Prefixes / Suffixes
You can add any custom (text) prefix/suffix to the value in the input using the `prefix` and `suffix` slots.
## Props
| Prop | Description | Default |
|-------------|------------------------------------------------|---------|
| `autofocus` | Autofocusses the input on render | `false` |
| `disabled` | Set the disabled state for the input | `false` |
| `monospace` | Render the entered value in the monospace font | `false` |
| `prefix` | Prefix the users value with a value | -- |
| `suffix` | Show a value at the end of the input | -- |
Note: all other attached attributes are bound to the input HTMLELement in the component. This allows you to attach any of the standard HTML attributes like `min`, `length`, or `pattern`.
## Slots
| Slot | Description | Data |
|-----------------|---------------------------------------------------|--------------------------------------------------|
| `prepend-outer` | Before the input | `{ disabled: boolean, value: string | number; }` |
| `prepend` | In the input, before the value, before the prefix | `{ disabled: boolean, value: string | number; }` |
| `append` | In the input, after the value, after the suffix | `{ disabled: boolean, value: string | number; }` |
| `append-outer` | After the input | `{ disabled: boolean, value: string | number; }` |
## Events
| Events | Description | Value |
|-----------------------|----------------------------------------------|-------|
| `input` | Updates `v-model` | `any` |
| `click:prepend-outer` | User clicks on content of outer prepend slot | -- |
| `click:prepend` | User clicks on content of inner prepend slot | -- |
| `click:append` | User clicks on content of inner append slot | -- |
| `click:append-outer` | User clicks on content of outer append slot | -- |
Note: all other listeners are bound to the input HTMLElement, allowing you to handle everything from `keydown` to `emptied`.

View File

@@ -0,0 +1,89 @@
import { withKnobs } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VInput from './v-input.vue';
import markdown from './v-input.readme.md';
Vue.component('v-input', VInput);
Vue.directive('focus', {});
export default {
title: 'Components / Input',
component: VInput,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const basic = () => ({
data() {
return {
value: ''
};
},
template: `
<div>
<v-input v-model="value" placeholder="Enter content..." />
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">
value: {{ value }}
</pre>
</div>
`
});
export const monospace = () => ({
data() {
return {
value: ''
};
},
template: `
<div>
<v-input v-model="value" placeholder="Enter content..." monospace />
</div>
`
});
export const disabled = () => `<v-input value="I'm disabled" disabled />`;
export const fullWidth = () => `
<v-input placeholder="Enter content..." full-width />
`;
export const prefixSuffix = () => `
<div>
<v-input prefix="$" value="14.99" style="margin-bottom: 20px" />
<v-input suffix="@rngr.org" value="rijk" />
</div>
`;
export const withSlots = () => ({
data() {
return {
value: ''
};
},
template: `
<div>
<v-input style="margin-bottom: 20px">
<template #prepend-outer><v-icon name="star" /></template>
<template #prepend><v-icon name="alarm" /></template>
<template #append><v-icon name="clear" /></template>
<template #append-outer><v-icon name="star" /></template>
</v-input>
<v-input style="margin-bottom: 20px">
<template #append><v-button small>Search</v-button></template>
</v-input>
<v-input v-model="value" style="margin-bottom: 20px">
<template #append-outer>{{value.length}} / 100</template>
</v-input>
<v-input>
<template #prepend-outer>prepend-outer</template>
<template #prepend>prepend</template>
<template #append-outer>append-outer</template>
<template #append>append</template>
</v-input>
</div>
`
});

View File

@@ -0,0 +1,71 @@
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.directive('focus', {});
import VInput from './v-input.vue';
describe('Input', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(VInput, { localVue });
});
it('Renders content in the correct slots', async () => {
component = mount(VInput, {
localVue,
slots: {
'prepend-outer': '<div>prepend-outer</div>',
prepend: '<div>prepend</div>',
append: '<div>append</div>',
'append-outer': '<div>append-outer</div>'
}
});
expect(component.find('.v-input > .prepend-outer > div ').html()).toBe(
'<div>prepend-outer</div>'
);
expect(component.find('.v-input > .append-outer > div ').html()).toBe(
'<div>append-outer</div>'
);
expect(component.find('.v-input > .input > .prepend > div ').html()).toBe(
'<div>prepend</div>'
);
expect(component.find('.v-input > .input > .append > div ').html()).toBe(
'<div>append</div>'
);
});
it('Renders prefix / suffix', async () => {
component.setProps({
prefix: 'Prefix',
suffix: 'Suffix'
});
await component.vm.$nextTick();
expect(component.find('.input .prefix').html()).toBe('<span class="prefix">Prefix</span>');
expect(component.find('.input .suffix').html()).toBe('<span class="suffix">Suffix</span>');
});
it('Sets the correct classes based on props', async () => {
component.setProps({
disabled: true,
monospace: true
});
await component.vm.$nextTick();
expect(component.find('.input').classes()).toEqual(['input', 'disabled', 'monospace']);
});
it('Emits just the value for the input event', async () => {
const input = component.find('input');
(input.element as HTMLInputElement).value = 'The value';
input.trigger('input');
expect(component.emitted('input')[0]).toEqual(['The value']);
});
});

View File

@@ -0,0 +1,147 @@
<template>
<div class="v-input">
<div v-if="$slots['prepend-outer']" class="prepend-outer">
<slot name="prepend-outer" :value="value" :disabled="disabled" />
</div>
<div class="input" :class="{ disabled, monospace, 'full-width': fullWidth }">
<div v-if="$slots.prepend" class="prepend">
<slot name="prepend" :value="value" :disabled="disabled" />
</div>
<span v-if="prefix" class="prefix">{{ prefix }}</span>
<input
v-bind="$attrs"
v-focus="autofocus"
v-on="_listeners"
:disabled="disabled"
:value="value"
/>
<span v-if="suffix" class="suffix">{{ suffix }}</span>
<div v-if="$slots.append" class="append">
<slot name="append" :value="value" :disabled="disabled" />
</div>
</div>
<div v-if="$slots['append-outer']" class="append-outer">
<slot name="append-outer" :value="value" :disabled="disabled" />
</div>
</div>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
export default createComponent({
inheritAttrs: false,
props: {
autofocus: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
prefix: {
type: String,
default: null
},
suffix: {
type: String,
default: null
},
monospace: {
type: Boolean,
default: false
},
fullWidth: {
type: Boolean,
default: false
},
value: {
type: [String, Number],
default: null
}
},
setup(props, { emit, listeners }) {
const _listeners = computed(() => ({
...listeners,
input: emitValue
}));
return { _listeners };
function emitValue(event: InputEvent) {
emit('input', (event.target as HTMLInputElement).value);
}
}
});
</script>
<style lang="scss" scoped>
.v-input {
display: flex;
align-items: center;
height: var(--input-height);
.prepend-outer {
margin-right: 8px;
}
.input {
height: 100%;
display: flex;
align-items: center;
border: var(--input-border-width) solid var(--input-border-color);
background-color: var(--input-background-color);
border-radius: var(--border-radius);
padding: var(--input-padding);
transition: border-color var(--fast) var(--transition);
.prepend {
margin-right: 8px;
}
&:not(.disabled):hover {
border-color: var(--input-border-color-hover);
}
&:not(.disabled):focus-within {
border-color: var(--input-border-color-focus);
}
&.disabled {
background-color: var(--input-background-color-disabled);
}
&.full-width {
width: 100%;
}
&.monospace {
input {
font-family: var(--family-monospace);
}
}
input {
appearance: none;
flex-grow: 1;
height: 100%;
border: none;
background-color: transparent;
}
.prefix,
.suffix {
color: var(--input-border-color-focus);
}
.append {
margin-left: 8px;
}
}
.append-outer {
margin-left: 8px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,27 @@
# Overlay
```html
<v-overlay :active="overlay">
<v-button @click="overlay = false">Close overlay</v-button>
</v-overlay>
```
The overlay is a fairly barebones component that's meant to be used with modals / confirms / other attention requiring actions.
## Color
The overlay component supports multiple colors. The color prop accepts any valid CSS color. CSS variable names can be passed as well.
## Props
| Prop | Description | Default |
|------------|---------------------------|-----------------------|
| `active` | Show / hide the overlay | `false` |
| `absolute` | Add `position: absolute;` | `false` |
| `color` | Color for the overlay | `--modal-smoke-color` |
| `z-index` | `z-index` for the overlay | `500` |
| `opacity` | Opacity for the overlay | `0.75` |
## Slots
The only slot is the default slot. All content in the overlay will be rendered in the center of the overlay.

View File

@@ -0,0 +1,90 @@
import {
withKnobs,
text,
boolean,
number,
optionsKnob as options,
color
} from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VOverlay from '../v-overlay';
import markdown from './v-overlay.readme.md';
Vue.component('v-overlay', VOverlay);
export default {
title: 'Components / Overlay',
component: VOverlay,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const interactive = () => ({
props: {
absolute: {
default: boolean('Absolute', false)
},
color: {
default: color('Color', '#263238')
},
zIndex: {
default: number('z-index', 500)
},
opacity: {
default: number('Opacity', 0.75)
}
},
data() {
return {
active: false
};
},
template: `
<div style="position: relative; padding: 50px; border: 3px dashed #eee; width: max-content;">
<v-button @click="active = true">Show overlay</v-button>
<v-overlay :active="active" :absolute="absolute" :color="color" :z-index="zIndex" :opacity="opacity">
<v-button @click="active = false">Close overlay</v-button>
</v-overlay>
</div>
`
});
export const withClick = () => ({
props: {
absolute: {
default: boolean('Absolute', false)
},
color: {
default: color('Color', '#263238')
},
zIndex: {
default: number('z-index', 500)
},
opacity: {
default: number('Opacity', 0.75)
}
},
data() {
return {
active: false
};
},
methods: {
click(event: MouseEvent) {
action('click')(event);
const self: any = this;
self.active = false;
}
},
template: `
<div style="position: relative; padding: 50px; border: 3px dashed #eee; width: max-content;">
<v-button @click="active = true">Show overlay</v-button>
<v-overlay :active="active" :absolute="absolute" :color="color" :z-index="zIndex" :opacity="opacity" @click="click" />
</div>
`
});

View File

@@ -0,0 +1,62 @@
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VOverlay from './v-overlay.vue';
describe('Overlay', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(VOverlay, {
localVue
});
});
it('Is invisible when active prop is false', () => {
expect(component.isVisible()).toBe(false);
});
it('Is visible when active is true', async () => {
component.setProps({ active: true });
await component.vm.$nextTick();
expect(component.isVisible()).toBe(true);
});
it('Sets position absolute based on absolute prop', async () => {
component.setProps({ active: true, absolute: true });
await component.vm.$nextTick();
expect(component.classes()).toContain('absolute');
});
it('Sets the inline styles based on props', async () => {
component.setProps({
active: true,
absolute: true,
color: '--red',
zIndex: 50,
opacity: 0.2
});
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-overlay-color']).toEqual('var(--red)');
expect((component.vm as any).styles['--_v-overlay-z-index']).toEqual(50);
expect((component.vm as any).styles['--_v-overlay-opacity']).toEqual(0.2);
});
it('Adds the has-click class when click event is passed', async () => {
const component = mount(VOverlay, {
localVue,
listeners: {
click: () => {}
}
});
expect(component.classes()).toContain('has-click');
});
it('Emits click event', async () => {
component.find('.v-overlay').trigger('click');
expect(component.emitted('click')[0]).toBeTruthy();
});
});

View File

@@ -0,0 +1,98 @@
<template>
<div
class="v-overlay"
v-show="active"
:class="{ active, absolute, 'has-click': hasClick }"
:style="styles"
@click="onClick"
>
<div class="overlay" />
<div v-if="active" class="content"><slot /></div>
</div>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
export default createComponent({
props: {
active: {
type: Boolean,
default: false
},
absolute: {
type: Boolean,
default: false
},
color: {
type: String,
default: '--modal-smoke-color'
},
zIndex: {
type: Number,
default: 500
},
opacity: {
type: Number,
default: 0.75
}
},
setup(props, { emit, listeners }) {
const styles = computed(() => ({
'--_v-overlay-color': parseCSSVar(props.color),
'--_v-overlay-z-index': props.zIndex,
'--_v-overlay-opacity': props.opacity
}));
const hasClick = computed<boolean>(() => listeners.hasOwnProperty('click'));
return { styles, hasClick, onClick };
function onClick(event: MouseEvent) {
emit('click', event);
}
}
});
</script>
<style lang="scss" scoped>
.v-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
z-index: var(--_v-overlay-z-index);
&.has-click {
cursor: pointer;
}
&.active {
pointer-events: auto;
}
&.absolute {
position: absolute;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--_v-overlay-color);
opacity: var(--_v-overlay-opacity);
}
.content {
position: relative;
}
}
</style>

View File

@@ -0,0 +1,4 @@
import VProgressLinear from './v-progress-linear.vue';
export { VProgressLinear };
export default VProgressLinear;

View File

@@ -0,0 +1,42 @@
# Progress (Linear)
```html
<v-progress-linear :value="75" />
```
## Colors
The linear progress component supports colors. The color prop accepts any valid CSS color. CSS variable names can be passed as well, prefixed with `--`:
```html
<v-progress-linear color="red" background-color="black" />
<v-progress-linear color="#efefef" background-color="#ff00aa" />
<v-progress-linear color="rgba(0, 25, 89, 0.8)" background-color="papayawhip" />
<v-progress-linear color="--blue-grey-500" background-color="--blue-grey-200" />
```
## Indeterminate
The progress indicator can be rendered in indeterminate mode by passing the `indeterminate` prop. Use this when it's unclear when the progress will be done.
## Props
| Prop | Description | Default |
|--------------------|-----------------------------------------------------------------------|--------------------------------------|
| `absolute` | Applies `position: absolute` | `false` |
| `background-color` | Sets the background color. Any CSS value or variable prefixed with -- | `--progress-background-color` |
| `bottom` | Align the progress bar to the bottom | `false` |
| `color` | Foreground color for the progress bar | `--progress-background-color-accent` |
| `fixed` | Applies `position: fixed;` to the element | `false` |
| `height` | Sets the height (in px) for the progress bar | `4` |
| `indeterminate` | Animates the bar, use when loading progress is unknown | `false` |
| `rounded` | Add a border radius to the bar | `false` |
| `top` | Align progress bar to the top of the parent container | `false` |
| `value` | Percentage value for current progress | `0` |
## Events
n/a
## Slots
The default slot can be used to render any value in the progress bar. Make sure to add the height prop to give the content some breathing room.

View File

@@ -0,0 +1,72 @@
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VProgressLinear from './v-progress-linear.vue';
describe('Progress (Linear)', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(VProgressLinear, { localVue });
});
it('Calculates the correct inline styles', async () => {
component.setProps({
color: 'red',
backgroundColor: '--blue-grey-50',
height: 55
});
await component.vm.$nextTick();
expect((component.vm as any).styles).toEqual({
'--_v-progress-linear-color': 'red',
'--_v-progress-linear-background-color': 'var(--blue-grey-50)',
'--_v-progress-linear-height': '55px'
});
});
it('Sets the correct classes based on the props', async () => {
component.setProps({
absolute: true,
bottom: false,
fixed: false,
indeterminate: false,
rounded: false,
top: true
});
await component.vm.$nextTick();
expect(component.classes()).toEqual(['v-progress-linear', 'absolute', 'top']);
component.setProps({
absolute: false,
bottom: true,
fixed: true,
indeterminate: false,
rounded: false,
top: false
});
await component.vm.$nextTick();
expect(component.classes()).toEqual(['v-progress-linear', 'bottom', 'fixed']);
component.setProps({
absolute: false,
bottom: false,
fixed: false,
indeterminate: true,
rounded: true,
top: false
});
await component.vm.$nextTick();
expect(component.classes()).toEqual(['v-progress-linear', 'indeterminate', 'rounded']);
});
});

View File

@@ -0,0 +1,146 @@
<template>
<div
class="v-progress-linear"
:style="styles"
:class="{
absolute,
bottom,
fixed,
indeterminate,
rounded,
top
}"
>
<div
class="inner"
:style="{
width: value + '%'
}"
/>
<slot :value="value" />
</div>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
export default createComponent({
props: {
absolute: {
type: Boolean,
default: false
},
backgroundColor: {
type: String,
default: '--progress-background-color'
},
bottom: {
type: Boolean,
default: false
},
color: {
type: String,
default: '--progress-background-color-accent'
},
fixed: {
type: Boolean,
default: false
},
height: {
type: Number,
default: 4
},
indeterminate: {
type: Boolean,
default: false
},
rounded: {
type: Boolean,
default: false
},
top: {
type: Boolean,
default: false
},
value: {
type: Number,
default: 0
}
},
setup(props) {
const styles = computed<object>(() => ({
'--_v-progress-linear-background-color': parseCSSVar(props.backgroundColor),
'--_v-progress-linear-color': parseCSSVar(props.color),
'--_v-progress-linear-height': props.height + 'px'
}));
return { styles };
}
});
</script>
<style lang="scss" scoped>
.v-progress-linear {
background-color: var(--_v-progress-linear-background-color);
height: var(--_v-progress-linear-height);
position: relative;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
.inner {
top: 0;
left: 0;
position: absolute;
height: 100%;
background-color: var(--_v-progress-linear-color);
}
&.absolute {
position: absolute;
}
&.bottom {
bottom: 0px;
}
&.fixed {
position: fixed;
}
&.indeterminate .inner {
width: 100% !important;
transform-origin: left;
will-change: transform;
animation: indeterminate 2s infinite;
position: relative;
}
&.rounded,
&.rounded .inner {
border-radius: calc(var(--_v-progress-linear-height) / 2);
}
&.top {
top: 0px;
}
}
@keyframes indeterminate {
0% {
transform: scaleX(0);
animation-timing-function: cubic-bezier(0.1, 0.6, 0.9, 0.5);
}
50% {
transform: scaleX(1) translateX(25%);
animation-timing-function: cubic-bezier(0.4, 0.1, 0.2, 0.9);
}
100% {
transform: scaleX(1) translateX(100%);
animation-timing-function: cubic-bezier(0.1, 0.6, 0.9, 0.5);
}
}
</style>

View File

@@ -0,0 +1,75 @@
import {
withKnobs,
color,
optionsKnob as options,
number,
text,
boolean
} from '@storybook/addon-knobs';
import Vue from 'vue';
import VProgressLinear from './v-progress-linear.vue';
import markdown from './v-progress-linear.readme.md';
Vue.component('v-progress-linear', VProgressLinear);
export default {
title: 'Components / Progress (linear)',
component: VProgressLinear,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const interactive = () => ({
props: {
absolute: {
default: boolean('Absolute', false)
},
backgroundColor: {
default: color('Background Color', '#cfd8dc')
},
bottom: {
default: boolean('Bottom', false)
},
color: {
default: color('Color', '#263238')
},
fixed: {
default: boolean('Fixed', false)
},
height: {
default: number('Height', 4)
},
indeterminate: {
default: boolean('Indeterminate', false)
},
rounded: {
default: boolean('Rounded', false)
},
top: {
default: boolean('Top', false)
},
value: {
default: number('Value (percentage)', 50)
}
},
template: `
<v-progress-linear
:absolute="absolute"
:backgroundColor="backgroundColor"
:bottom="bottom"
:color="color"
:fixed="fixed"
:height="height"
:indeterminate="indeterminate"
:rounded="rounded"
:top="top"
:value="value"
/>`
});
export const withSlot = () => `
<v-progress-linear :height="25" :value="25" rounded>25%</v-progress-linear>
`;

View File

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

View File

@@ -0,0 +1,23 @@
# Sheet
```html
<v-sheet></v-sheet>
```
A sheet is a component that holds other components. It provides a visual difference (layer) on the page. It's often used when grouping fields.
## Sizing
The sheet component has props for `height`, `width`, `min-height`, `min-width`, `max-height`, and `max-width`. All of these props are in pixels.
## Color
The color prop accepts any valid CSS color. CSS variable names can be passed as well, prefixed with `--`:
```html
<v-sheet color="#eee"></v-sheet>
<v-sheet color="papayawhip"></v-sheet>
<v-sheet color="rgba(255, 153, 84, 0.4"></v-sheet>
<v-sheet color="--input-background-color-alt"></v-sheet>
```

View File

@@ -0,0 +1,88 @@
import {
withKnobs,
text,
boolean,
number,
color,
optionsKnob as options
} from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VSheet from './v-sheet.vue';
import VIcon from '../v-icon/';
import markdown from './v-sheet.readme.md';
Vue.component('v-sheet', VSheet);
export default {
title: 'Components / Sheet',
component: VSheet,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const interactive = () => ({
props: {
text: {
default: text(
'Text',
'A sheet is component holds other components. It provides an extra visual layer on the page.'
)
},
color: {
default: color('Color', '#cfd8dc')
},
width: {
default: number('Width', 0)
},
height: {
default: number('Height', 0)
},
minWidth: {
default: number('Min Width', 0)
},
minHeight: {
default: number('Min Height', 0)
},
maxWidth: {
default: number('Max Width', 0)
},
maxHeight: {
default: number('Max Height', 0)
}
},
template: `
<v-sheet
:color="color"
:width="width"
:height="height"
:minWidth="minWidth"
:minHeight="minHeight"
:maxWidth="maxWidth"
:maxHeight="maxHeight"
>{{ text }}</v-sheet>`
});
export const colorsSizes = () => `
<div>
<v-sheet
color="#ef9a9a"
:width="150"
:height="150"
/>
<v-sheet
color="#81D4FA"
:width="550"
:height="50"
/>
<v-sheet
color="#E6EE9C"
:width="220"
:height="500"
/>
</div>
`;

View File

@@ -0,0 +1,38 @@
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VSheet from './v-sheet.vue';
describe('Sheet', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(VSheet, {
localVue
});
});
it('Sets the right inline styles for the given props', async () => {
component.setProps({
height: 150,
width: 200,
minHeight: 250,
minWidth: 300,
maxHeight: 350,
maxWidth: 400,
color: '--red'
});
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-sheet-height']).toEqual('150px');
expect((component.vm as any).styles['--_v-sheet-width']).toEqual('200px');
expect((component.vm as any).styles['--_v-sheet-min-height']).toEqual('250px');
expect((component.vm as any).styles['--_v-sheet-min-width']).toEqual('300px');
expect((component.vm as any).styles['--_v-sheet-max-height']).toEqual('350px');
expect((component.vm as any).styles['--_v-sheet-max-width']).toEqual('400px');
expect((component.vm as any).styles['--_v-sheet-color']).toEqual('var(--red)');
});
});

View File

@@ -0,0 +1,113 @@
<template>
<div class="v-sheet" :style="styles">
<slot />
</div>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
export default createComponent({
props: {
color: {
type: String,
default: '--input-background-color-alt'
},
minHeight: {
type: Number,
default: null
},
maxHeight: {
type: Number,
default: null
},
height: {
type: Number,
default: null
},
minWidth: {
type: Number,
default: null
},
maxWidth: {
type: Number,
default: null
},
width: {
type: Number,
default: null
}
},
setup(props) {
type Styles = {
'--_v-sheet-color': string;
'--_v-sheet-min-height'?: string;
'--_v-sheet-max-height'?: string;
'--_v-sheet-height'?: string;
'--_v-sheet-min-width'?: string;
'--_v-sheet-max-width'?: string;
'--_v-sheet-width'?: string;
};
const styles = computed(() => {
const styles: Styles = {
'--_v-sheet-color': parseCSSVar(props.color)
};
if (props.minHeight) {
styles['--_v-sheet-min-height'] = props.minHeight + 'px';
}
if (props.maxHeight) {
styles['--_v-sheet-max-height'] = props.maxHeight + 'px';
}
if (props.height) {
styles['--_v-sheet-height'] = props.height + 'px';
}
if (props.minWidth) {
styles['--_v-sheet-min-width'] = props.minWidth + 'px';
}
if (props.maxWidth) {
styles['--_v-sheet-max-width'] = props.maxWidth + 'px';
}
if (props.width) {
styles['--_v-sheet-width'] = props.width + 'px';
}
return styles;
});
return { styles };
}
});
</script>
<style lang="scss" scoped>
.v-sheet {
--_v-sheet-height: auto;
--_v-sheet-min-height: var(--input-height);
--_v-sheet-max-height: none;
--_v-sheet-width: auto;
--_v-sheet-min-width: none;
--_v-sheet-max-width: none;
padding: 8px;
border-radius: var(--border-radius);
background-color: var(--_v-sheet-color);
height: var(--_v-sheet-height);
min-height: var(--_v-sheet-min-height);
max-height: var(--_v-sheet-max-height);
width: var(--_v-sheet-width);
min-width: var(--_v-sheet-min-width);
max-width: var(--_v-sheet-max-width);
overflow: auto;
}
</style>

View File

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

View File

@@ -0,0 +1,62 @@
# Slider
```html
<v-slider v-model="value" :min="0" :max="100" />
```
## Thumb Label
You can show the current value of the slider when the user is sliding by enabling the thumb label. This can be done by setting the `show-thumb-label` prop:
```html
<v-slider v-model="value" :min="0" :max="100" show-thumb-label />
```
## Ticks
You can render an indicator for every step of the slider. This allows the user to know what kind of steps are available when sliding the slider. You can enable this with the `show-ticks` prop.
```html
<v-slider v-model="value" :min="0" :max="100" show-ticks />
```
## Prepend / Append slot
You can add any custom content before and after the slider (inline). This can be used to render things like buttons to decrease / increase the value, or a label that shows a preview of what the value with a unit is going to be.
```html
<v-slider>
<template #prepend>
<v-icon name="star" />
</template>
<template #append>
Value: {{ value }}
</template>
</v-slider>
```
## Props
| Prop | Description | Default |
|--------------------|--------------------------------------------------------------|-----------------------------|
| `max` | Maximum allowed value | `100` |
| `min` | Minimum allowed value | `0` |
| `show-thumb-label` | Show the thumb label on drag of the thumb | `false` |
| `show-ticks` | Show tick for each step | `false` |
| `step` | In what step the value can be entered | `1` |
| `thumb-color` | Color of the thumb and label | `--slider-thumb-color` |
| `track-color` | Color of the slider track | `--slider-track-color` |
| `track-fill-color` | Color of the filled part of the slider track (left of thumb) | `--slider-track-fill-color` |
| `value` | Current value of slider. Can be used with `v-model` | `50` |
## Events
| Event | Description | Value |
|-----------------|---------------------------------------------|----------|
| `change` | Fires only when the user releases the thumb | `number` |
| `input` | Fires continuously | `number` |
## Slots
| Slot | Description | Props |
|---------------|------------------------------------|---------------------|
| `append` | Inserted after the slider track | -- |
| `prepend` | Inserted before the slider track | -- |
| `thumb-label` | Custom content for the thumb label | `{ value: number }` |

View File

@@ -0,0 +1,248 @@
import { action } from '@storybook/addon-actions';
import { withKnobs, color, boolean, number } from '@storybook/addon-knobs';
import Vue from 'vue';
import markdown from './v-slider.readme.md';
import VIcon from '../v-icon/';
import VSlider from './v-slider.vue';
Vue.component('v-slider', VSlider);
Vue.component('v-icon', VIcon);
export default {
title: 'Components / Slider',
decorators: [withKnobs],
component: VSlider,
parameters: {
notes: markdown
}
};
export const interactive = () => ({
data() {
return {
value: 15
};
},
props: {
trackColor: {
default: color('Track Color', '#cfd8dc')
},
trackFillColor: {
default: color('Track Fill Color', '#37474f')
},
thumbColor: {
default: color('Thumb Color', '#37474f')
},
showThumbLabel: {
default: boolean('Show Thumb Label', false)
},
showTicks: {
default: boolean('Show Ticks', false)
},
max: {
default: number('Max value', 25)
},
min: {
default: number('Min value', 5)
},
step: {
default: number('Step', 1)
}
},
methods: {
onInput: action('input'),
onChange: action('change'),
clickPrepend: action('click:prepend'),
clickAppend: action('click:append')
},
template: `
<div>
<v-slider
v-model="value"
:track-color="trackColor"
:track-fill-color="trackFillColor"
:thumb-color="thumbColor"
:max="max"
:min="min"
:show-ticks="showTicks"
:show-thumb-label="showThumbLabel"
@input="onInput"
@change="onChange"
/>
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">
value: {{value}}
</pre>
</div>
`
});
export const withTicks = () => ({
data() {
return {
value: 12
};
},
methods: {
onInput: action('input'),
onChange: action('change'),
clickPrepend: action('click:prepend'),
clickAppend: action('click:append')
},
template: `
<v-slider
v-model="value"
:min="5"
:max="15"
show-ticks
@input="onInput"
@change="onChange"
/>
`
});
export const withThumbLabel = () => ({
data() {
return {
value: 12
};
},
methods: {
onInput: action('input'),
onChange: action('change'),
clickPrepend: action('click:prepend'),
clickAppend: action('click:append')
},
template: `
<v-slider
:min="5"
:max="15"
v-model="value"
show-thumb-label
@input="onInput"
@change="onChange"
/>
`
});
export const appendSlot = () => ({
data() {
return {
value: 12
};
},
methods: {
onInput: action('input'),
onChange: action('change'),
clickPrepend: action('click:prepend'),
clickAppend: action('click:append')
},
template: `
<v-slider
:min="5"
:max="15"
v-model="value"
show-thumb-label
@input="onInput"
@change="onChange"
>
<template #append>
{{ value }} / 15
</template>
</v-slider>
`
});
export const prependSlot = () => ({
data() {
return {
value: 12
};
},
methods: {
onInput: action('input'),
onChange: action('change'),
clickPrepend: action('click:prepend'),
clickAppend: action('click:append')
},
template: `
<v-slider
:min="5"
:max="15"
v-model="value"
show-thumb-label
@input="onInput"
@change="onChange"
>
<template #prepend>
<v-icon name="star" />
</template>
</v-slider>
`
});
export const slots = () => ({
data() {
return {
value: 12
};
},
methods: {
onInput: action('input'),
onChange: action('change'),
clickMinus(event: MouseEvent) {
const self: any = this;
if (self.value > 5) {
self.value = self.value - 1;
}
},
clickPlus() {
const self: any = this;
if (self.value < 15) {
self.value = self.value + 1;
}
}
},
template: `
<v-slider
:min="5"
:max="15"
v-model="value"
show-thumb-label
@input="onInput"
@change="onChange"
>
<template #prepend>
<v-icon name="remove" @click="clickMinus" />
</template>
<template #append>
<v-icon name="add" @click="clickPlus" />
</template>
</v-slider>
`
});
export const thumbLabelSlot = () => ({
data() {
return {
value: 12
};
},
methods: {
onInput: action('input'),
onChange: action('change')
},
template: `
<v-slider
:min="5"
:max="15"
v-model="value"
show-thumb-label
@input="onInput"
@change="onChange"
>
<template #thumb-label>
Units: {{ value }}
</template>
</v-slider>
`
});

View File

@@ -0,0 +1,72 @@
import { createLocalVue, mount, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VSlider from './v-slider.vue';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
describe('Slider', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(VSlider, { localVue });
});
it('Sets the correct inline styles for given props', async () => {
component.setProps({
trackColor: '--red',
trackFillColor: 'papayawhip',
thumbColor: '#abcabc'
});
await component.vm.$nextTick();
expect((component.vm as any).styles).toEqual({
'--_v-slider-percentage': 50,
'--_v-slider-track-color': 'var(--red)',
'--_v-slider-track-fill-color': 'papayawhip',
'--_v-slider-thumb-color': '#abcabc'
});
});
it('Calculates the correct percentage based on props/value', async () => {
component.setProps({
min: 5,
max: 25,
value: 10
});
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-slider-percentage']).toEqual(25);
});
it('Emits just the value on input', async () => {
const input = component.find('input');
(input.element as HTMLInputElement).value = '500';
input.trigger('input');
expect(component.emitted('input')[0]).toEqual([500]);
});
it('Emits just the value on change', async () => {
const input = component.find('input');
(input.element as HTMLInputElement).value = '500';
input.trigger('change');
expect(component.emitted('change')[0]).toEqual([500]);
});
it('Renders the prepend/append slots', async () => {
const component = mount(VSlider, {
localVue,
slots: {
prepend: '<div>prepend</div>',
append: '<div>append</div>'
}
});
expect(component.find('.prepend > div').html()).toBe('<div>prepend</div>');
expect(component.find('.append > div').html()).toBe('<div>append</div>');
});
});

View File

@@ -0,0 +1,255 @@
<template>
<div class="v-slider" :style="styles">
<div v-if="$slots['prepend']" class="prepend">
<slot name="prepend" :value="value" />
</div>
<div class="slider">
<input
type="range"
:value="value"
:max="max"
:min="min"
:step="step"
@change="onChange"
@input="onInput"
/>
<div class="fill" />
<div v-if="showTicks" class="ticks">
<span class="tick" v-for="i in (max - min) / step + 1" :key="i" />
</div>
<div v-if="showThumbLabel" class="thumb-label-wrapper">
<div class="thumb-label">
<slot name="thumb-label" :value="value">
{{ value }}
</slot>
</div>
</div>
</div>
<div v-if="$slots['append']" class="append">
<slot name="append" :value="value" />
</div>
</div>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
import { ChangeEvent } from 'react';
export default createComponent({
props: {
trackColor: {
type: String,
default: '--slider-track-color'
},
trackFillColor: {
type: String,
default: '--slider-track-fill-color'
},
thumbColor: {
type: String,
default: '--slider-thumb-color'
},
showThumbLabel: {
type: Boolean,
default: false
},
max: {
type: Number,
default: 100
},
min: {
type: Number,
default: 0
},
step: {
type: Number,
default: 1
},
showTicks: {
type: Boolean,
default: false
},
value: {
type: Number,
default: 50
}
},
setup(props, { emit, listeners }) {
const styles = computed(() => ({
'--_v-slider-track-color': parseCSSVar(props.trackColor),
'--_v-slider-track-fill-color': parseCSSVar(props.trackFillColor),
'--_v-slider-thumb-color': parseCSSVar(props.thumbColor),
'--_v-slider-percentage': ((props.value - props.min) / (props.max - props.min)) * 100
}));
return {
styles,
onChange,
onInput
};
function onChange(event: ChangeEvent) {
const target = event.target as HTMLInputElement;
emit('change', Number(target.value));
}
function onInput(event: InputEvent) {
const target = event.target as HTMLInputElement;
emit('input', Number(target.value));
}
}
});
</script>
<style lang="scss" scoped>
.v-slider {
display: flex;
align-items: center;
.prepend {
margin-right: 8px;
}
.slider {
flex-grow: 1;
position: relative;
top: -3px;
input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 2px;
/*
* The vendor specific styling for the tracks needs to be separate into individual
* statements. In browsers, if one of the statements is unknown, the whole selector is
* invalidated. We're using these 'local' mixins to facilitate that.
*/
@mixin v-slider-track {
height: 2px;
background: var(--_v-slider-track-color);
box-shadow: none;
border: none;
border-radius: 2px;
}
&::-webkit-slider-runnable-track {
@include v-slider-track;
}
&::-moz-range-track {
@include v-slider-track;
}
@mixin v-slider-thumb {
-webkit-appearance: none;
appearance: none;
box-shadow: none;
border: none;
height: 14px;
width: 14px;
border-radius: 50%;
background: var(--_v-slider-thumb-color);
margin-top: -6px;
cursor: ew-resize;
box-shadow: 0 0 0 4px var(--input-background-color);
z-index: 3;
position: relative;
}
&::-webkit-slider-thumb {
@include v-slider-thumb;
}
&::-moz-range-thumb {
@include v-slider-thumb;
}
}
.fill {
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(2px) scaleX(calc(var(--_v-slider-percentage) / 100));
transform-origin: left;
height: 2px;
background-color: var(--_v-slider-track-fill-color);
pointer-events: none;
z-index: 2;
}
.ticks {
opacity: 0;
position: absolute;
top: 11px;
left: 0;
width: 100%;
height: 4px;
pointer-events: none;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 7px;
transition: opacity var(--fast) var(--transition);
.tick {
display: inline-block;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--_v-slider-track-fill-color);
}
}
.thumb-label-wrapper {
position: absolute;
top: 100%;
left: 7px;
width: calc(100% - 14px);
overflow: visible;
}
.thumb-label {
opacity: 0;
position: absolute;
width: max-content;
left: calc(var(--_v-slider-percentage) * 1%);
transform: translateX(-50%);
top: 10px;
color: var(--input-text-color);
background-color: var(--input-background-color-alt);
border-radius: var(--border-radius);
font-size: var(--input-font-size);
padding: 4px 8px;
transition: opacity var(--fast) var(--transition);
&:before {
content: '';
position: absolute;
top: -4px;
left: calc(50%);
width: 10px;
height: 10px;
border-radius: var(--border-radius);
transform: translateX(-50%) rotate(45deg);
background-color: var(--input-background-color-alt);
}
}
&:active {
.thumb-label,
.ticks {
opacity: 1;
}
}
}
.append {
margin-left: 8px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,62 @@
# Spinner
```html
<v-spinner />
```
## Colors
The color of the spinner can be changed through the `color` prop. This prop accepts any valid CSS color string, or a global CSS var prefixed with `--`:
```html
<v-spinner color="red" />
<v-spinner color="#abcabc" />
<v-spinner color="rgba(255, 125, 81, 0.2)" />
<v-spinner color="--amber" />
<v-spinner color="--danger" />
```
The background color can be set in similar fashion:
```html
<v-spinner background-color="red" />
<v-spinner background-color="#abcabc" />
<v-spinner background-color="rgba(255, 125, 81, 0.2)" />
<v-spinner background-color="--amber" />
<v-spinner background-color="--danger" />
```
## Sizes
The spinner component supports the following sizes through the use of props:
* x-small
* small
* (default)
* large
* x-large
Alternatively, you can force the font-size through the `size` prop:
```html
<v-spinner x-small />
<v-spinner small />
<v-spinner />
<v-spinner large />
<v-spinner x-large />
<v-spinner :size="64" />
```
## Props
| Prop | Description | Default |
|-------------|-----------------------------------|-------------------------------------|
| `color` | Color of the spinner | `--loading-background-color-accent` |
| `size` | Size of the spinner in px | -- |
| `line-size` | Size of the border of the spinner | -- |
| `speed` | Speed of the spin animation | `1s` |
| `x-small` | Render extra small | `false` |
| `small` | Render small | `false` |
| `large` | Render large | `false` |
| `x-large` | Render extra large | `false` |

View File

@@ -0,0 +1,94 @@
import { withKnobs, color, optionsKnob as options, number, text } from '@storybook/addon-knobs';
import Vue from 'vue';
import VSpinner from './v-spinner.vue';
import markdown from './v-spinner.readme.md';
Vue.component('v-spinner', VSpinner);
export default {
title: 'Components / Spinner',
component: VSpinner,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const interactive = () => ({
props: {
color: {
default: color('Color', '#263238')
},
backgroundColor: {
default: color('Background Color', '#cfd8dc')
},
size: {
default: options(
'Size',
{
'Extra Small': 'xSmall',
Small: 'small',
'(default)': 'default',
Large: 'large',
'Extra Large': 'xLarge'
},
'default',
{
display: 'select'
}
)
},
speed: {
default: text('Speed (css, eg 200ms)', '1s')
},
customSize: {
default: number('Size (in px)', 0)
},
customLineSize: {
default: number('Line Size (in px)', 0)
}
},
template: `
<v-spinner
:color="color"
:background-color="backgroundColor"
:x-small="size === 'xSmall'"
:small="size === 'small'"
:large="size === 'large'"
:x-large="size === 'xLarge'"
:size="customSize"
:line-size="customLineSize"
:speed="speed"
/>`
});
export const colors = () => `
<div style="display: flex; justify-content: space-around">
<v-spinner color="--red" />
<v-spinner color="transparent" background-color="--blue" />
<v-spinner color="--green" />
<v-spinner color="--amber" background-color="--red" />
<v-spinner color="--purple" />
</div>
`;
export const sizes = () => `
<div style="display: flex; justify-content: space-around">
<v-spinner x-small />
<v-spinner small />
<v-spinner />
<v-spinner large />
<v-spinner x-large />
</div>
`;
export const speed = () => `
<div style="display: flex; justify-content: space-around">
<v-spinner speed="5s" />
<v-spinner speed="2.5s" />
<v-spinner />
<v-spinner speed="500ms" />
<v-spinner speed="250ms" />
</div>
`;

View File

@@ -0,0 +1,99 @@
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VSpinner from './v-spinner.vue';
describe('Spinner', () => {
let component: Wrapper<Vue>;
beforeEach(() => (component = mount(VSpinner, { localVue })));
describe('Styles', () => {
test('Color', async () => {
component.setProps({ color: '--red' });
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-spinner-color']).toBe('var(--red)');
});
test('Background Color', async () => {
component.setProps({ backgroundColor: '--red' });
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-spinner-background-color']).toBe(
'var(--red)'
);
});
test('Size', async () => {
component.setProps({ size: 58 });
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-spinner-size']).toBe('58px');
});
test('Line Size', async () => {
component.setProps({ lineSize: 24 });
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-spinner-line-size']).toBe('24px');
});
test('Speed', async () => {
component.setProps({ speed: '5s' });
await component.vm.$nextTick();
expect((component.vm as any).styles['--_v-spinner-speed']).toBe('5s');
});
});
describe('Sizes', () => {
test('Extra Small', () => {
component.setProps({
xSmall: true,
small: false,
large: false,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('x-small'));
});
test('Small', () => {
component.setProps({
xSmall: false,
small: true,
large: false,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('small'));
});
test('Large', () => {
component.setProps({
xSmall: false,
small: false,
large: true,
xLarge: false
});
component.vm.$nextTick(() => expect(component.classes()).toContain('large'));
});
test('Extra Large', () => {
component.setProps({
xSmall: false,
small: false,
large: false,
xLarge: true
});
component.vm.$nextTick(() => expect(component.classes()).toContain('x-large'));
});
it('Uses the smallest size prop provided (small)', () => {
component.setProps({
xSmall: false,
small: true,
large: false,
xLarge: true
});
component.vm.$nextTick(() => expect(component.classes()).toContain('small'));
});
});
});

View File

@@ -0,0 +1,137 @@
<template>
<div class="v-spinner" :class="sizeClass" :style="styles"></div>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
export default createComponent({
props: {
color: {
type: String,
default: '--loading-background-color-accent'
},
backgroundColor: {
type: String,
default: '--loading-background-color'
},
size: {
type: Number,
default: null
},
lineSize: {
type: Number,
default: null
},
speed: {
type: String,
default: '1s'
},
xSmall: {
type: Boolean,
default: false
},
small: {
type: Boolean,
default: false
},
large: {
type: Boolean,
default: false
},
xLarge: {
type: Boolean,
default: false
}
},
setup(props) {
type Styles = {
'--_v-spinner-color': string;
'--_v-spinner-background-color': string;
'--_v-spinner-speed': string;
'--_v-spinner-size'?: string;
'--_v-spinner-line-size'?: string;
};
const styles = computed(() => {
const styles: Styles = {
'--_v-spinner-color': parseCSSVar(props.color),
'--_v-spinner-background-color': parseCSSVar(props.backgroundColor),
'--_v-spinner-speed': props.speed
};
if (props.size) {
styles['--_v-spinner-size'] = `${props.size}px`;
}
if (props.lineSize) {
styles['--_v-spinner-line-size'] = `${props.lineSize}px`;
}
return styles;
});
const sizeClass = computed<string | null>(() => {
if (props.xSmall) return 'x-small';
if (props.small) return 'small';
if (props.large) return 'large';
if (props.xLarge) return 'x-large';
return null;
});
return {
styles,
sizeClass
};
}
});
</script>
<style lang="scss" scoped>
.v-spinner {
--_v-spinner-size: 28px;
--_v-spinner-line-size: 3px;
width: var(--_v-spinner-size);
height: var(--_v-spinner-size);
position: relative;
border-radius: 100%;
border: var(--_v-spinner-line-size) solid var(--_v-spinner-background-color);
border-top: var(--_v-spinner-line-size) solid var(--_v-spinner-color);
background-color: transparent;
animation: rotate var(--_v-spinner-speed) infinite linear;
&.x-small {
--_v-spinner-size: 12px;
--_v-spinner-line-size: 2px;
}
&.small {
--_v-spinner-size: 16px;
--_v-spinner-line-size: 3px;
}
&.large {
--_v-spinner-size: 48px;
--_v-spinner-line-size: 4px;
}
&.x-large {
--_v-spinner-size: 64px;
--_v-spinner-line-size: 5px;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

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

View File

@@ -0,0 +1,67 @@
# Switch
## Basic usage
```html
<v-switch v-model="checked" label="Receive newsletter" />
```
## Colors
The switch component accepts any CSS color value, or variable name:
```html
<v-switch color="#abcabc" />
<v-switch color="rgba(125, 125, 198, 0.5)" />
<v-switch color="--red" />
<v-switch color="--input-border-color" />
```
## Boolean vs arrays
Just as with regular checkboxes, you can use `v-model` with both an array and a boolean:
```html
<template>
<v-switch v-model="withBoolean" />
<v-switch v-model="withArray" value="red" />
<v-switch v-model="withArray" value="blue" />
<v-switch v-model="withArray" value="green" />
</template>
<script>
export default {
data() {
return {
withBoolean: false,
withArray: ['red', 'green']
}
}
}
</script>
```
Keep in mind to pass the `value` prop with a unique value when using arrays in `v-model`.
## Events
| Event | Description | Data |
|----------|----------------------------|----------------------------|
| `change` | New state for the checkbox | Boolean or array of values |
## Props
| Prop | Description | Default |
|--------------|--------------------------------------------------------------------------------------------------------|-----------------------------------|
| `value` | Value for switch. Similar to value attr on checkbox type input in HTML | `--` |
| `inputValue` | Value that's used with `v-model`. Either boolean or array of values | `false` |
| `label` | Label for the checkbox | `--` |
| `color` | Color for the checked state of the checkbox. Either CSS var name (fe `--red`) or other valid CSS color | `--input-background-color-active` |
## Slots
| Slot | Description |
|---------|------------------------------------------------------------------------------------------------|
| `label` | Allows custom markup and HTML to be rendered inside the label. Will override the `label` prop. |

View File

@@ -0,0 +1,104 @@
import {
withKnobs,
text,
boolean,
number,
optionsKnob as options,
color
} from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import VSwitch from '../v-switch/';
import markdown from './v-switch.readme.md';
Vue.component('v-switch', VSwitch);
export default {
title: 'Components / Switch',
component: VSwitch,
decorators: [withKnobs],
parameters: {
notes: markdown
}
};
export const booleanState = () => ({
methods: {
onChange: action('change')
},
data() {
return {
checked: false
};
},
template: `
<div>
<v-switch v-model="checked" @change="onChange" />
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">{{checked}}</pre>
</div>
`
});
export const arrayState = () => ({
methods: {
onChange: action('change')
},
data() {
return {
options: ['html', 'css']
};
},
template: `
<div>
<v-switch style="margin-bottom: 20px" v-model="options" value="html" @change="onChange" label="HTML" />
<v-switch style="margin-bottom: 20px" v-model="options" value="css" @change="onChange" label="CSS" />
<v-switch style="margin-bottom: 20px" v-model="options" value="js" @change="onChange" label="JS" />
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">{{options}}</pre>
</div>
`
});
export const disabled = () =>
`<div><v-switch style="margin-bottom: 20px" label="Disabled" disabled /><v-switch style="margin-bottom: 20px" :inputValue="true" label="Disabled" disabled /></div>`;
export const colors = () => ({
methods: {
onChange: action('change')
},
data() {
return {
options: ['red', 'yellow', 'custom']
};
},
props: {
customColor: {
default: color('Custom color', '#4CAF50')
}
},
template: `
<div>
<v-switch style="margin-bottom: 20px" v-model="options" value="red" @change="onChange" color="--red" label="Red" />
<v-switch style="margin-bottom: 20px" v-model="options" value="blue" @change="onChange" color="--blue" label="Blue" />
<v-switch style="margin-bottom: 20px" v-model="options" value="yellow" @change="onChange" color="--amber" label="Yellow" />
<v-switch style="margin-bottom: 20px" v-model="options" value="custom" @change="onChange" :color="customColor" label="Custom..." />
</div>
`
});
export const htmlLabel = () => ({
methods: {
onChange: action('change')
},
data() {
return {
checked: true
};
},
template: `
<v-switch v-model="checked" @change="onChange">
<template #label>
Any <i>custom</i> markup in here
</template>
</v-switch>
`
});

View File

@@ -0,0 +1,110 @@
import { mount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VSwitch from './v-switch.vue';
describe('Switch', () => {
it('Renders passed label', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
label: 'Turn me on'
}
});
expect(component.find('span[class="label"]').text()).toContain('Turn me on');
});
it('Uses the correct inline styles for custom colors', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
color: '#123123'
}
});
expect((component.vm as any).colorStyle['--_v-switch-color']).toBe('#123123');
});
it('Renders as checked when inputValue `true` is given', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
inputValue: true
}
});
expect((component.vm as any).isChecked).toBe(true);
});
it('Calculates check for array inputValue', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
value: 'red',
inputValue: ['red']
}
});
expect((component.vm as any).isChecked).toBe(true);
});
it('Emits true when state is false', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
inputValue: false
}
});
const button = component.find('button');
button.trigger('click');
expect(component.emitted().change[0][0]).toBe(true);
});
it('Disables the button when disabled prop is set', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
disabled: true
}
});
const button = component.find('button');
expect(Object.keys(button.attributes())).toContain('disabled');
});
it('Appends value to array', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
value: 'red',
inputValue: ['blue', 'green']
}
});
const button = component.find('button');
button.trigger('click');
expect(component.emitted().change[0][0]).toEqual(['blue', 'green', 'red']);
});
it('Removes value from array', () => {
const component = mount(VSwitch, {
localVue,
propsData: {
value: 'red',
inputValue: ['blue', 'green', 'red']
}
});
const button = component.find('button');
button.trigger('click');
expect(component.emitted().change[0][0]).toEqual(['blue', 'green']);
});
});

View File

@@ -0,0 +1,163 @@
<template>
<button
class="v-switch"
@click="toggleInput"
type="button"
role="switch"
:aria-pressed="isChecked ? 'true' : 'false'"
:style="colorStyle"
:disabled="disabled"
>
<span class="switch" />
<span class="label">
<slot name="label">{{ label }}</slot>
</span>
</button>
</template>
<script lang="ts">
import { createComponent, computed } from '@vue/composition-api';
import parseCSSVar from '@/utils/parse-css-var';
export default createComponent({
model: {
prop: 'inputValue',
event: 'change'
},
props: {
value: {
type: String,
default: null
},
inputValue: {
type: [Boolean, Array],
default: false
},
label: {
type: String,
default: null
},
color: {
type: String,
default: '--input-background-color-active'
},
disabled: {
type: Boolean,
default: false
}
},
setup(props, { emit }) {
const isChecked = computed<boolean>(() => {
if (props.inputValue instanceof Array) {
return props.inputValue.includes(props.value);
}
return props.inputValue === true;
});
const colorStyle = computed(() => {
return {
'--_v-switch-color': parseCSSVar(props.color)
};
});
return { isChecked, toggleInput, colorStyle };
function toggleInput(): void {
if (props.inputValue instanceof Array) {
let newValue = [...props.inputValue];
if (isChecked.value === false) {
newValue.push(props.value);
} else {
newValue.splice(newValue.indexOf(props.value), 1);
}
emit('change', newValue);
} else {
emit('change', !isChecked.value);
}
}
}
});
</script>
<style lang="scss" scoped>
.v-switch {
font-size: 0;
appearance: none;
background-color: transparent;
border-radius: 0;
border: none;
display: flex;
align-items: center;
.switch {
display: inline-block;
height: 24px;
width: 44px;
border-radius: 12px;
border: var(--input-border-width) solid var(--input-border-color);
position: relative;
transition: var(--fast) var(--transition);
transition-property: background-color border;
vertical-align: middle;
&:not(:disabled)hover {
border-color: var(--input-border-color-hover);
}
&:focus {
outline: 0;
}
&::after {
content: '';
width: 16px;
height: 16px;
position: absolute;
top: 2px;
left: 2px;
display: block;
background-color: var(--input-border-color);
border-radius: 8px;
transition: transform var(--fast) var(--transition);
}
}
&[aria-pressed='true'] {
&:not(:disabled) {
.switch {
background-color: var(--_v-switch-color);
border-color: var(--_v-switch-color);
&::after {
background-color: var(--input-text-color-active);
}
}
}
.switch::after {
transform: translateX(20px);
}
}
.label:not(:empty) {
font-size: var(--input-font-size);
margin-left: 8px;
vertical-align: middle;
}
&:disabled {
cursor: not-allowed;
.switch {
background-color: var(--input-background-color-disabled);
}
.label {
color: var(--popover-text-color-disabled);
}
}
}
</style>

View File

@@ -0,0 +1,416 @@
import VueCompositionAPI from '@vue/composition-api';
import { mount, createLocalVue, Wrapper, shallowMount } from '@vue/test-utils';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VCheckbox from '../v-checkbox/';
import VIcon from '../v-icon/';
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
import TableHeader from './_table-header.vue';
describe('Table / Header', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(TableHeader, {
localVue,
propsData: {
headers: [
{
text: 'Column 1',
value: 'col1'
}
],
sort: {
by: null,
desc: false
}
}
});
});
it('Gets the right classes for the passed props', async () => {
component.setProps({
sort: {
by: 'col1',
desc: true
}
});
await component.vm.$nextTick();
const classes = (component.vm as any).getClassesForHeader({
text: 'Column 1',
value: 'col1',
align: 'center',
sortable: true
});
expect(classes).toEqual(['align-center', 'sortable', 'sort-desc']);
});
it('Emits the correct update event on sorting changes', async () => {
component.setProps({
sort: {
by: null,
desc: true
}
});
await component.vm.$nextTick();
component.find('th .content').trigger('click');
expect(component.emitted('update:sort')[0]).toEqual([{ by: 'col1', desc: false }]);
component.setProps({
sort: {
by: 'col1',
desc: false
}
});
await component.vm.$nextTick();
component.find('th .content').trigger('click');
expect(component.emitted('update:sort')[1]).toEqual([{ by: 'col1', desc: true }]);
component.setProps({
sort: {
by: 'col1',
desc: true
}
});
await component.vm.$nextTick();
component.find('th .content').trigger('click');
expect(component.emitted('update:sort')[2]).toEqual([{ by: null, desc: false }]);
});
it("Doesn't emit the update sort event when dragging", async () => {
(component.vm as any).dragging = true;
component.find('th .content').trigger('click');
expect(component.emitted('update:sort')).toEqual(undefined);
});
it('Emits toggle-select-all on checkbox click', async () => {
component.setProps({
showSelect: true,
sort: {
by: null,
desc: false
}
});
await component.vm.$nextTick();
component.find(VCheckbox).trigger('click');
expect(component.emitted('toggle-select-all')[0]).toEqual([true]);
});
it('Prevents unsortable columns from being sorted', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1',
sortable: false
}
]
});
await component.vm.$nextTick();
component.find('th .content').trigger('click');
expect(component.emitted()).toEqual({});
});
it('Renders correct thead for provided headers', async () => {
component.setProps({
headers: [
{
text: 'Col1',
value: 'col1',
align: 'left',
sortable: true
},
{
text: 'Col2',
value: 'col2',
align: 'left',
sortable: true
}
]
});
await component.vm.$nextTick();
expect(component.find('th:first-child').html()).toContain('Col1');
expect(component.find('th:nth-child(2)').html()).toContain('Col2');
});
it('Adds the align class to the header', async () => {
component.setProps({
headers: [
{
text: 'Col1',
value: 'col1',
align: 'left'
},
{
text: 'Col2',
value: 'col2',
align: 'center'
},
{
text: 'Col3',
value: 'col3',
align: 'right'
}
]
});
await component.vm.$nextTick();
expect(component.find('th:first-child').classes()).toContain('align-left');
expect(component.find('th:nth-child(2)').classes()).toContain('align-center');
expect(component.find('th:nth-child(3)').classes()).toContain('align-right');
});
it('Generates the correct inline styles for column widths', async () => {
component.setProps({
headers: [],
sortDesc: false
});
await component.vm.$nextTick();
const styles = (component.vm as any).getStyleForHeader({
text: 'Col2',
value: 'col2',
align: 'center',
sortable: true,
width: 150
});
expect(styles).toEqual({
width: '150px'
});
});
it('Renders the provided element in the nested scoped slot for the header', async () => {
const component = mount(TableHeader, {
localVue,
propsData: {
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
sort: {
by: null,
desc: false
}
},
scopedSlots: {
'header.col2': '<template slot-scope="{header}"><p>{{ header.text }}</p></template>'
}
});
expect(component.find('.v-table_table-header th:nth-child(2) .content > *').html()).toEqual(
'<p>Column 2</p>'
);
});
it('Toggles between manual and non-manual sort', async () => {
component.setProps({
showManualSort: true,
sort: {
by: null,
desc: false
}
});
await component.vm.$nextTick();
component.find('.manual.cell').trigger('click');
expect(component.emitted('update:sort')[0]).toEqual([
{
by: '$manual',
desc: false
}
]);
component.setProps({
showManualSort: true,
sort: {
by: '$manual',
desc: false
}
});
await component.vm.$nextTick();
component.find('.manual.cell').trigger('click');
expect(component.emitted('update:sort')[1]).toEqual([
{
by: null,
desc: false
}
]);
});
it('Sets the dragging state correctly based on mouse interaction', async () => {
component.setProps({
showResize: true,
headers: [
{
text: 'Col1',
value: 'col1',
align: 'left',
sortable: true
},
{
text: 'Col2',
value: 'col2',
align: 'left',
sortable: true
}
]
});
await component.vm.$nextTick();
expect((component.vm as any).dragging).toBe(false);
component.find('.resize-handle').trigger('mousedown');
expect((component.vm as any).dragging).toBe(true);
window.dispatchEvent(new Event('mouseup'));
expect((component.vm as any).dragging).toBe(false);
});
it('Calculates the new header size correctly', async () => {
component.setProps({
showResize: true,
headers: [
{
text: 'Col1',
value: 'col1',
align: 'left',
sortable: true
},
{
text: 'Col2',
value: 'col2',
align: 'left',
sortable: true
}
]
});
await component.vm.$nextTick();
// Set internal dragging state to dummy values after starting resize
(component.vm as any).dragging = true;
(component.vm as any).dragStartX = 0;
(component.vm as any).dragStartWidth = 100;
(component.vm as any).dragHeader = {
text: 'Col1',
value: 'col1',
align: 'left',
sortable: true
};
await component.vm.$nextTick();
(component.vm as any).onMouseMove({
pageX: 50
});
expect(component.emitted('update:headers')[0][0][0].width).toBe(150);
});
it("Doesn't trigger on mousemove if dragging is false", async () => {
(component.vm as any).onMouseMove({
pageX: 50
});
expect(component.emitted('update:headers')).toBe(undefined);
});
it('Calculates the right width CSS property based on header', async () => {
component.setProps({
headers: [
{
text: 'Col1',
value: 'col1',
align: 'left',
sortable: true
},
{
text: 'Col2',
value: 'col2',
align: 'left',
sortable: true,
width: 175
},
{
text: 'Col3',
value: 'col3',
align: 'left',
sortable: true,
width: 250
}
]
});
await component.vm.$nextTick();
const { getStyleForHeader } = component.vm as any;
expect(
getStyleForHeader(
{
width: null
},
0
)
).toEqual(null);
expect(
getStyleForHeader(
{
width: 175
},
1
)
).toEqual({ width: '175px' });
expect(
getStyleForHeader(
{
width: 175
},
2
)
).toEqual({ width: 'auto' });
});
});

View File

@@ -0,0 +1,298 @@
<template>
<thead class="v-table_table-header" :class="{ dragging }">
<tr :class="{ fixed }">
<th v-if="showManualSort" class="manual cell" @click="toggleManualSort">
<v-icon name="sort" class="drag-handle" small />
</th>
<th v-if="showSelect" class="select cell">
<v-checkbox
:inputValue="allItemsSelected"
:indeterminate="someItemsSelected"
@change="toggleSelectAll"
/>
</th>
<th
v-for="(header, index) in headers"
:key="header.value"
:class="getClassesForHeader(header)"
:style="getStyleForHeader(header, index)"
class="cell"
>
<div class="content" @click="changeSort(header)">
<slot :name="`header.${header.value}`" :header="header">{{ header.text }}</slot>
<v-icon v-if="header.sortable" name="sort" class="sort-icon" small />
</div>
<span
class="resize-handle"
v-if="showResize && index !== headers.length - 1"
@click.stop
@mousedown="onResizeHandleMouseDown(header, $event)"
/>
</th>
</tr>
</thead>
</template>
<script lang="ts">
import { createComponent, ref, onMounted, onBeforeUnmount, PropType } from '@vue/composition-api';
import useEventListener from '@/compositions/event-listener';
import { Alignment, Header, Sort } from './types';
import { throttle, clone } from 'lodash';
export default createComponent({
props: {
headers: {
type: Array as PropType<Header[]>,
required: true
},
sort: {
type: Object as PropType<Sort>,
required: true
},
showSelect: {
type: Boolean,
default: false
},
showResize: {
type: Boolean,
default: false
},
showManualSort: {
type: Boolean,
default: false
},
someItemsSelected: {
type: Boolean,
default: false
},
allItemsSelected: {
type: Boolean,
default: false
},
fixed: {
type: Boolean,
default: false
}
},
setup(props, { emit }) {
const dragging = ref<boolean>(false);
const dragStartX = ref<number>(0);
const dragStartWidth = ref<number>(0);
const dragHeader = ref<Header>(null);
useEventListener(window, 'mousemove', throttle(onMouseMove, 20));
useEventListener(window, 'mouseup', onMouseUp);
return {
changeSort,
dragging,
dragHeader,
dragStartWidth,
dragStartX,
getClassesForHeader,
getStyleForHeader,
onMouseMove,
onResizeHandleMouseDown,
toggleManualSort,
toggleSelectAll
};
function getClassesForHeader(header: Header) {
const classes: string[] = [];
if (header.align) {
classes.push(`align-${header.align}`);
}
if (header.sortable) {
classes.push('sortable');
}
if (props.sort.by === header.value) {
if (props.sort.desc === false) {
classes.push('sort-asc');
} else {
classes.push('sort-desc');
}
}
return classes;
}
function getStyleForHeader(header: Header, index: number) {
if (header.width !== null) {
let width: string;
if (index === props.headers.length - 1) {
width = 'auto';
} else {
width = header.width + 'px';
}
return { width };
}
return null;
}
/**
* If current sort is not this field, use this field in ascending order
* If current sort is field, reverse sort order to descending
* If current sort is field and sort is desc, set sort field to null (default)
*/
function changeSort(header: Header) {
if (header.sortable === false) return;
if (dragging.value === true) return;
if (header.value === props.sort.by) {
if (props.sort.desc === false) {
emit('update:sort', {
by: props.sort.by,
desc: true
});
} else {
emit('update:sort', {
by: null,
desc: false
});
}
} else {
emit('update:sort', {
by: header.value,
desc: false
});
}
}
function toggleSelectAll() {
emit('toggle-select-all', !props.allItemsSelected);
}
function onResizeHandleMouseDown(header: Header, event: MouseEvent) {
const target = event.target as HTMLDivElement;
const parent = target.parentElement as HTMLTableHeaderCellElement;
dragging.value = true;
dragStartX.value = event.pageX;
dragStartWidth.value = parent.offsetWidth;
dragHeader.value = header;
}
function onMouseMove(event: MouseEvent) {
if (dragging.value === true) {
const newWidth = dragStartWidth.value + (event.pageX - dragStartX.value);
const currentHeaders = clone(props.headers);
const newHeaders = currentHeaders.map((existing: Header) => {
if (existing.value === dragHeader.value?.value) {
return {
...existing,
width: Math.max(50, newWidth)
};
}
return existing;
});
emit('update:headers', newHeaders);
}
}
function onMouseUp(event: MouseEvent) {
if (dragging.value === true) {
dragging.value = false;
}
}
function toggleManualSort() {
if (props.sort.by === '$manual') {
emit('update:sort', {
by: null,
desc: false
});
} else {
emit('update:sort', {
by: '$manual',
desc: false
});
}
}
}
});
</script>
<style lang="scss" scoped>
.v-table_table-header {
.cell {
background-color: var(--table-background-color);
padding: 0 20px;
border-bottom: 1px solid var(--table-head-border-color);
height: 48px;
font-size: 14px;
font-weight: var(--weight-bold);
position: relative;
}
.sortable {
.sort-icon {
opacity: 0;
transform: scaleY(-1) translateX(4px);
transition: opacity var(--fast) var(--transition);
position: absolute;
}
&:hover .sort-icon {
opacity: 0.5;
}
&.sort-asc,
&.sort-desc {
.sort-icon {
opacity: 1;
}
}
&.sort-desc {
.sort-icon {
transform: scaleY(1) translateX(4px);
}
}
}
:not(&.dragging) .sortable {
cursor: pointer;
}
.select,
.manual {
width: 42px;
padding-right: 0;
}
.fixed th {
position: sticky;
top: 0;
}
.resize-handle {
cursor: ew-resize;
width: 5px;
height: 100%;
position: absolute;
right: 0;
top: 0;
&::after {
content: '';
display: block;
width: 1px;
position: relative;
height: 100%;
left: 2px;
background-color: var(--table-drag-handle);
}
&:hover::after {
background-color: var(--table-drag-handle-hover);
}
}
}
</style>

View File

@@ -0,0 +1,123 @@
import VueCompositionAPI from '@vue/composition-api';
import { mount, createLocalVue, Wrapper, shallowMount } from '@vue/test-utils';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VCheckbox from '../v-checkbox/';
import VIcon from '../v-icon/';
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
import TableRow from './_table-row.vue';
describe('Table / Row', () => {
let component: Wrapper<Vue>;
beforeEach(() => {
component = mount(TableRow, {
localVue,
propsData: {
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
item: {
col1: 'Test',
col2: 'Test 2'
}
}
});
});
it('Renders right amount of cells per row', async () => {
expect(component.find('.v-table_table-row').findAll('td').length).toBe(2);
});
it('Renders the provided element in the nested scoped slot', async () => {
const component = mount(TableRow, {
localVue,
propsData: {
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
item: {
col1: 'Test 1 Col 1',
col2: 'Test 1 Col 2'
}
},
scopedSlots: {
'item.col2': '<template slot-scope="{item}"><p>{{ item.col2 }}</p></template>'
}
});
expect(component.find('.v-table_table-row td:nth-child(2) > *').html()).toEqual(
'<p>Test 1 Col 2</p>'
);
});
it('Adds the align class', async () => {
component.setProps({
headers: [
{
text: 'Col1',
value: 'col1',
sortable: true,
align: 'left'
},
{
text: 'Col2',
value: 'col2',
sortable: true,
align: 'center'
},
{
text: 'Col3',
value: 'col3',
sortable: true,
align: 'right'
}
]
});
await component.vm.$nextTick();
expect(component.find('td:first-child').classes()).toContain('align-left');
expect(component.find('td:nth-child(2)').classes()).toContain('align-center');
expect(component.find('td:nth-child(3)').classes()).toContain('align-right');
});
it('Emits item selection changes on checkbox click', async () => {
component.setProps({
showSelect: true
});
await component.vm.$nextTick();
component.find(VCheckbox).trigger('click');
expect(component.emitted('item-selected')[0]).toEqual([
{
item: {
col1: 'Test',
col2: 'Test 2'
},
value: true
}
]);
});
});

View File

@@ -0,0 +1,117 @@
<template>
<tr
class="v-table_table-row"
:class="{ subdued, clickable: hasClickListener }"
@click="$emit('click', $event)"
>
<td v-if="showManualSort" class="manual cell">
<v-icon
name="drag_handle"
class="drag-handle"
:color="
sortedManually ? '--input-border-color' : '--input-background-color-disabled'
"
/>
</td>
<td v-if="showSelect" class="select cell">
<v-checkbox :inputValue="isSelected" @change="toggleSelect" />
</td>
<td
class="cell"
v-for="header in headers"
:class="getClassesForCell(header)"
:key="header.value"
:style="{ height: height + 'px' }"
>
<slot :name="`item.${header.value}`" :item="item">{{ item[header.value] }}</slot>
</td>
</tr>
</template>
<script lang="ts">
import { createComponent, PropType } from '@vue/composition-api';
import { Header, Sort } from './types';
export default createComponent({
props: {
headers: {
type: Array as PropType<Header[]>,
required: true
},
item: {
type: Object,
required: true
},
showSelect: {
type: Boolean,
default: false
},
showManualSort: {
type: Boolean,
default: false
},
isSelected: {
type: Boolean,
default: false
},
subdued: {
type: Boolean,
default: false
},
sortedManually: {
type: Boolean,
default: false
},
hasClickListener: {
type: Boolean,
default: false
},
height: {
type: Number,
default: 48
}
},
setup(props, { emit }) {
return { getClassesForCell, toggleSelect };
function getClassesForCell(header: Header) {
const classes: string[] = [];
if (header.align) {
classes.push(`align-${header.align}`);
}
return classes;
}
function toggleSelect() {
emit('item-selected', {
item: props.item,
value: !props.isSelected
});
}
}
});
</script>
<style lang="scss" scoped>
.v-table_table-row {
.cell {
border-bottom: 1px solid var(--table-row-border-color);
padding: 0 20px;
background-color: var(--table-background-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.subdued {
opacity: 0.3;
}
&.clickable:hover .cell {
background-color: var(--highlight);
cursor: pointer;
}
}
</style>

View File

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

View File

@@ -0,0 +1,21 @@
export type Alignment = 'left' | 'center' | 'right';
export type HeaderRaw = {
text: string;
value: string;
align?: Alignment;
sortable?: boolean;
width?: number | null;
};
export type Header = Required<HeaderRaw>;
export type ItemSelectEvent = {
value: boolean;
item: any;
};
export type Sort = {
by: string | null;
desc: boolean;
};

View File

@@ -0,0 +1,141 @@
# Table
```html
<v-table
:headers="[
{
text: 'Name',
value: 'name'
},
{
text: 'Phone Number',
value: 'tel'
},
{
text: 'Contact',
value: 'person'
}
]"
:items="[
{
name: 'Amsterdam',
tel: '020-1122334',
person: 'Mariann Rumble'
},
{
name: 'New Haven',
tel: '(203) 687-9900',
person: 'Helenka Killely'
}
]"
/>
```
## Headers
| Property | Description | Default |
|------------|--------------------------------------------------------------|---------|
| `text`* | Text displayed in the column | -- |
| `value`* | Name of the object property that holds the value of the item | -- |
| `align` | Text alignment of value. One of `left`, `center`, `right` | `left` |
| `sortable` | If the column can be sorted on | `true` |
| `width` | Custom width of the column in px | -- |
## Custom element / component for header
You can override the displayed header name by using the dynamic `header.[name]` slot. `[name]` is the `value` property in the header item for this column sent to `headers`.
```html
<v-table
:headers="headers"
:items="items"
>
<template #header.name="{ header }">
<v-button>{{ header.text }}</v-button>
</template>
</v-table>
```
In this slot, you have access to the `header` through the scoped slot binding.
## Custom element / component for cell value
You can override the columns in a row by using the dynamic `item.[name]` slot. `[name]` is the `value` property in the header item for this column sent to `headers`.
```html
<v-table
:headers="headers"
:items="items"
>
<template #item.name="{ item }">
<v-button>{{ item.name }}</v-button>
</template>
</v-table>
```
In this slot, you have access to the `item` through the scoped slot binding.
## Resizable rows
Adding the `show-resize` prop allows the user to resize the columns at will. You can keep your headers updated by using the `.sync` modifier or listening to the `update:headers` event:
```html
<template>
<v-table :headers.sync="headers" :items="[]" show-resize>
</template>
<script>
import { createComponent, ref } from '@vue/composition-api';
import { HeaderRaw } from '@/components/v-table/types';
export default createComponent({
setup() {
const headers = ref<HeaderRaw[]>([
{
text: 'Column 1',
value: 'col1',
width: 150
},
{
text: 'Column 1',
value: 'col1',
width: 300
}
]);
return { headers };
}
});
</script>
```
## Props
| Prop | Description | Default |
|----------------|---------------------------------------------------------------------|---------|
| `headers`* | What columns to show in the table. Supports the `.sync` modifier | -- |
| `items`* | The individual items to render as rows | -- |
| `item-key` | Primary key of the item. Used for keys / selections | `id` |
| `sort-by` | What column / order to sort by. Supports the `.sync` modifier | -- |
| `show-select` | Show checkboxes | `false` |
| `show-resize` | Show resize handlers | `false` |
| `selection` | What items are selected. Can be used with `v-model` as well | `[]` |
| `fixed-header` | Make the header fixed | `false` |
| `height` | A fixed height (in px) for the table | -- |
## Events
| Event | Description | Value |
|------------------|------------------------------------------------|---------------------------------|
| `update:sort` | `.sync` event for `sort` prop | `{ by: string, desc: boolean }` |
| `update:headers` | `.sync` event for `headers` prop | `HeaderRaw[]` |
| `item-selected` | Emitted when an item is selected or deselected | `{ item: any, value: boolean }` |
| `select` | Emitted when selected items change | `any[]` |
## Slots
| Slot | Description |
|------------------|----------------------------------|
| `header.[value]` | Override individual header cells |
| `item.[value]` | Override individual row cells |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
import VueCompositionAPI from '@vue/composition-api';
import { mount, createLocalVue, Wrapper, shallowMount } from '@vue/test-utils';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VCheckbox from '../v-checkbox/';
import VIcon from '../v-icon/';
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
import VTable from './v-table.vue';
describe('Table', () => {
let component: Wrapper<Vue>;
beforeEach(
() => (component = mount(VTable, { localVue, propsData: { headers: [], items: [] } }))
);
it('Renders the correct amount of rows for the given items', async () => {
component.setProps({ items: [{}, {}, {}] });
await component.vm.$nextTick();
expect(component.findAll('.v-table_table-row').length).toBe(3);
});
it('Adds the defaults to the passed headers', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
}
]
});
await component.vm.$nextTick();
expect((component.vm as any)._headers).toEqual([
{
text: 'Column 1',
value: 'col1',
sortable: true,
align: 'left',
width: null
}
]);
});
it('Sorts the items based on the passed props', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
sort: {
by: 'col1',
desc: false
}
});
await component.vm.$nextTick();
expect((component.vm as any)._items).toEqual([
{
col1: 'A',
col2: 3
},
{
col1: 'B',
col2: 2
},
{
col1: 'C',
col2: 1
}
]);
});
it('Calculates if all items are selected', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
selection: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
]
});
await component.vm.$nextTick();
expect((component.vm as any).allItemsSelected).toEqual(true);
expect((component.vm as any).someItemsSelected).toEqual(false);
});
it('Calculates if some items are selected', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
selection: [
{
col1: 'A',
col2: 3
}
]
});
await component.vm.$nextTick();
expect((component.vm as any).allItemsSelected).toEqual(false);
expect((component.vm as any).someItemsSelected).toEqual(true);
});
it('Handles sort by updates coming from header', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
]
});
await component.vm.$nextTick();
component.find('th .content').trigger('click');
expect((component.vm as any)._sort.by).toEqual('col1');
expect(component.emitted('update:sort')[0]).toEqual([{ by: 'col1', desc: false }]);
});
it('Handles sort desc updates coming from header', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
sort: {
by: 'col1',
desc: false
}
});
await component.vm.$nextTick();
component.find('th .content').trigger('click');
expect(component.emitted('update:sort')[0]).toEqual([{ by: 'col1', desc: true }]);
});
it('Updates selection correctly', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
selection: [],
showSelect: true
});
await component.vm.$nextTick();
component.find('.v-table_table-row .select > *').trigger('click');
expect(component.emitted('select')[0]).toEqual([
[
{
col1: 'A',
col2: 3
}
]
]);
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
selection: [
{
col1: 'A',
col2: 3
}
],
showSelect: true,
itemKey: 'col1'
});
await component.vm.$nextTick();
component.find('.v-table_table-row .select > *').trigger('click');
expect(component.emitted('select')[1]).toEqual([[]]);
});
it('Calculates selected state per row', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
selection: [
{
col1: 'A',
col2: 3
}
],
showSelect: true,
itemKey: 'col1'
});
await component.vm.$nextTick();
expect(
(component.vm as any).getSelectedState({
col1: 'A',
col2: 3
})
).toEqual(true);
expect(
(component.vm as any).getSelectedState({
col1: 'C',
col2: 1
})
).toEqual(false);
});
it('Selects all items if header checkbox is clicked', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
selection: [
{
col1: 'A',
col2: 3
}
],
showSelect: true,
itemKey: 'col1'
});
await component.vm.$nextTick();
component.find('.v-table_table-header .select > *').trigger('click');
expect(component.emitted('select')[0]).toEqual([
[
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
]
]);
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
],
items: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
selection: [
{
col1: 'A',
col2: 3
},
{
col1: 'C',
col2: 1
},
{
col1: 'B',
col2: 2
}
],
showSelect: true,
itemKey: 'col1'
});
await component.vm.$nextTick();
component.find('.v-table_table-header .select > *').trigger('click');
expect(component.emitted('select')[1]).toEqual([[]]);
});
it('Sets the correct inline styles for given height', async () => {
component.setProps({
headers: [],
items: [],
height: 50
});
await component.vm.$nextTick();
expect((component.vm as any).styles).toEqual({
height: '50px'
});
});
describe('Sorting', () => {
it('Sorts the items by the given sort prop internally', async () => {
component.setProps({
items: [
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
],
sort: {
by: 'col1',
desc: false
}
});
await component.vm.$nextTick();
expect((component.vm as any)._items).toEqual([
{
col1: 'A'
},
{
col1: 'B'
},
{
col1: 'C'
}
]);
});
it('Sorts the items in descending order if sort.desc is set to true', async () => {
component.setProps({
items: [
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
],
sort: {
by: 'col1',
desc: true
}
});
await component.vm.$nextTick();
expect((component.vm as any)._items).toEqual([
{
col1: 'C'
},
{
col1: 'B'
},
{
col1: 'A'
}
]);
});
it('Does not sort the items if the server-sort prop is set', async () => {
component.setProps({
items: [
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
],
sort: {
by: 'col1',
desc: false
},
serverSort: true
});
await component.vm.$nextTick();
expect((component.vm as any)._items).toEqual([
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
]);
});
it('Does not sort if manual sorting is activated', async () => {
component.setProps({
items: [
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
],
sort: {
by: '$manual',
desc: false
}
});
await component.vm.$nextTick();
expect((component.vm as any)._items).toEqual([
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
]);
});
});
it('Emits the update:items event when the internal items array is updated', async () => {
component.setProps({
items: []
});
await component.vm.$nextTick();
(component.vm as any)._items = [
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
];
await component.vm.$nextTick();
expect(component.emitted('update:items')[0]).toEqual([
[
{
col1: 'A'
},
{
col1: 'C'
},
{
col1: 'B'
}
]
]);
});
it('Emits updated headers without default values', async () => {
component.setProps({
headers: [
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2'
}
]
});
await component.vm.$nextTick();
(component.vm as any)._headers = [
{
text: 'Column 1',
value: 'col1',
width: null // default, should be removed
},
{
text: 'Column 2',
value: 'col2',
width: 150 // should be staged
}
];
await component.vm.$nextTick();
expect(component.emitted('update:headers')[0]).toEqual([
[
{
text: 'Column 1',
value: 'col1'
},
{
text: 'Column 2',
value: 'col2',
width: 150
}
]
]);
});
it('Passes on indexes from VueDraggable drop event', async () => {
(component.vm as any).onEndDrag({
oldIndex: 0,
newIndex: 5
});
await component.vm.$nextTick();
expect(component.emitted('drop')[0]).toEqual([{ oldIndex: 0, newIndex: 5 }]);
});
});

View File

@@ -0,0 +1,346 @@
<template>
<div class="v-table" :style="styles" :class="{ loading }">
<table>
<table-header
:headers.sync="_headers"
:sort.sync="_sort"
:show-select="showSelect"
:show-resize="showResize"
:some-items-selected="someItemsSelected"
:all-items-selected="allItemsSelected"
:fixed="fixedHeader"
:show-manual-sort="showManualSort"
@toggle-select-all="onToggleSelectAll"
>
<template v-for="header in _headers" #[`header.${header.value}`]>
<slot :header="header" :name="`header.${header.value}`" />
</template>
</table-header>
<thead v-if="loading" class="loading-indicator">
<th :colspan="_headers.length">
<v-progress-linear indeterminate v-if="loading" :height="2" />
</th>
</thead>
<tbody v-if="loading && items.length === 0">
<tr class="loading-text">
<td :colspan="_headers.length">{{ loadingText }}</td>
</tr>
</tbody>
<draggable
v-else
v-model="_items"
tag="tbody"
handle=".drag-handle"
:disabled="_sort.by !== '$manual'"
@end="onEndDrag"
>
<table-row
v-for="item in _items"
:key="item[itemKey]"
:headers="_headers"
:item="item"
:show-select="showSelect"
:show-manual-sort="showManualSort"
:is-selected="getSelectedState(item)"
:subdued="loading"
:sorted-manually="_sort.by === '$manual'"
:has-click-listener="hasRowClick"
:height="rowHeight"
@click="hasRowClick ? $emit('click:row', item) : null"
@item-selected="onItemSelected"
>
<template v-for="header in _headers" #[`item.${header.value}`]>
<slot :item="item" :name="`item.${header.value}`" />
</template>
</table-row>
</draggable>
</table>
</div>
</template>
<script lang="ts">
import { VNode } from 'vue';
import { createComponent, computed, ref, watch, Ref, PropType } from '@vue/composition-api';
import { Header, HeaderRaw, ItemSelectEvent, Sort } from './types';
import TableHeader from './_table-header.vue';
import TableRow from './_table-row.vue';
import { sortBy, clone, mapValues, forEach, pick } from 'lodash';
const draggable = require('vuedraggable');
const { i18n } = require('@/lang/');
const HeaderDefaults: Header = {
text: '',
value: '',
align: 'left',
sortable: true,
width: null
};
export default createComponent({
components: {
TableHeader,
TableRow,
draggable
},
model: {
prop: 'selection',
event: 'select'
},
props: {
headers: {
type: Array as PropType<HeaderRaw[]>,
required: true
},
items: {
type: Array as PropType<object[]>,
required: true
},
itemKey: {
type: String,
default: 'id'
},
sort: {
type: Object as PropType<Sort>,
default: null
},
showSelect: {
type: Boolean,
default: false
},
showResize: {
type: Boolean,
default: false
},
showManualSort: {
type: Boolean,
default: false
},
selection: {
type: Array as PropType<any[]>,
default: () => []
},
fixedHeader: {
type: Boolean,
default: false
},
height: {
type: Number,
default: null
},
loading: {
type: Boolean,
default: false
},
loadingText: {
type: String,
default: i18n.t('loading')
},
serverSort: {
type: Boolean,
default: false
},
rowHeight: {
type: Number,
default: 48
}
},
setup(props, { slots, emit, listeners }) {
const _headers = computed({
get: () => {
return props.headers.map((header: HeaderRaw) => ({
...HeaderDefaults,
...header
}));
},
set: (newHeaders: Header[]) => {
emit(
'update:headers',
// We'll return the original headers with the updated values, so we don't stage
// all the default values
newHeaders.map((header, index) => {
const keysThatArentDefault: string[] = [];
forEach(header, (value, key: string) => {
const objKey = key as keyof Header;
if (value !== HeaderDefaults[objKey]) {
keysThatArentDefault.push(key);
}
});
return pick(header, keysThatArentDefault);
})
);
}
});
// In case the sort prop isn't used, we'll use this local sort state as a fallback.
// This allows the table to allow inline sorting on column ootb without the need for
const _localSort = ref<Sort>({
by: null,
desc: false
});
const _sort = computed({
get: () => props.sort || _localSort.value,
set: (newSort: Sort) => {
emit('update:sort', newSort);
_localSort.value = newSort;
}
});
const _items = computed({
get: () => {
if (props.serverSort === true || _sort.value.by === '$manual') {
return props.items;
}
if (_sort.value.by === null) return props.items;
const itemsSorted = sortBy(props.items, [_sort.value.by]);
if (_sort.value.desc === true) return itemsSorted.reverse();
return itemsSorted;
},
set: (value: object[]) => {
emit('update:items', value);
}
});
const allItemsSelected = computed<boolean>(() => {
return props.selection.length === props.items.length;
});
const someItemsSelected = computed<boolean>(() => {
return props.selection.length > 0 && allItemsSelected.value === false;
});
type Styles = {
height?: string;
};
const styles = computed<object>(() => {
const styles: Styles = {};
if (props.height) {
styles.height = props.height + 'px';
}
return styles;
});
const hasRowClick = computed<boolean>(() => listeners.hasOwnProperty('click:row'));
return {
_headers,
_items,
_sort,
allItemsSelected,
getSelectedState,
onItemSelected,
onToggleSelectAll,
someItemsSelected,
styles,
onEndDrag,
hasRowClick
};
function onItemSelected(event: ItemSelectEvent) {
emit('item-selected', event);
const selection: any[] = clone(props.selection);
if (event.value === true) {
selection.push(event.item);
} else {
const itemIndex: number = selection.findIndex(
(item: any) => item[props.itemKey] === event.item[props.itemKey]
);
selection.splice(itemIndex, 1);
}
emit('select', selection);
}
function getSelectedState(item: any) {
const selectedKeys = props.selection.map((item: any) => item[props.itemKey]);
return selectedKeys.includes(item[props.itemKey]);
}
function onToggleSelectAll(value: boolean) {
if (value === true) {
emit('select', clone(props.items));
} else {
emit('select', []);
}
}
interface VueDraggableDropEvent extends CustomEvent {
oldIndex: number;
newIndex: number;
}
function onEndDrag(event: VueDraggableDropEvent) {
emit('drop', { oldIndex: event.oldIndex, newIndex: event.newIndex });
}
}
});
</script>
<style lang="scss" scoped>
.v-table {
position: relative;
overflow: auto;
table {
border-spacing: 0;
width: 100%;
table-layout: fixed;
}
&.loading {
table {
pointer-events: none;
}
.loading-indicator {
height: auto;
padding: 0;
border: none;
th {
padding: 0;
}
}
.loading-text {
td {
padding: 16px;
}
color: var(--input-placeholder-color);
text-align: center;
}
}
::v-deep {
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.sortable-ghost {
.cell {
background-color: var(--highlight);
}
}
}
}
</style>

View File

@@ -0,0 +1,54 @@
import VueCompositionAPI, { ref } from '@vue/composition-api';
import useEventListener from './event-listener';
import mountComposition from '../../.jest/mount-composition';
describe('Compositions / Event Listener', () => {
it('Adds passed event listener onMounted', () => {
const map: any = {};
window.addEventListener = jest.fn((event, cb) => {
map[event] = cb;
});
window.removeEventListener = jest.fn((event, cb) => {
delete map[event];
});
const handler = () => {};
const component = mountComposition(() => {
useEventListener(window, 'keydown', handler);
});
expect(map.keydown).toBe(handler);
component.destroy();
expect(map.keydown).toBe(undefined);
});
it('Uses the value if the target is a ref', () => {
const target = ref(window);
const map: any = {};
window.addEventListener = jest.fn((event, cb) => {
map[event] = cb;
});
window.removeEventListener = jest.fn((event, cb) => {
delete map[event];
});
const handler = () => {};
const component = mountComposition(() => {
useEventListener(target, 'keydown', handler);
});
expect(map.keydown).toBe(handler);
component.destroy();
expect(map.keydown).toBe(undefined);
});
});

View File

@@ -0,0 +1,18 @@
import { onMounted, onBeforeUnmount, Ref, isRef } from '@vue/composition-api';
export default function useEventListener<T extends EventTarget, E extends Event>(
target: T | Ref<T>,
type: string,
handler: (this: T, evt: E) => void,
options?: AddEventListenerOptions
) {
onMounted(() => {
const t = isRef(target) ? target.value : target;
t.addEventListener(type, handler as (evt: Event) => void, options);
});
onBeforeUnmount(() => {
const t = isRef(target) ? target.value : target;
t.removeEventListener(type, handler as (evt: Event) => void, options);
});
}

View File

@@ -0,0 +1,47 @@
import VueCompositionAPI, { watch } from '@vue/composition-api';
import useWindowSize from './window-size';
import mountComposition from '../../.jest/mount-composition';
describe('Compositions / Window Size', () => {
it('Adds passed event listener onMounted', async () => {
let testWidth: number = 0;
const component = mountComposition(() => {
const { width } = useWindowSize();
watch(width, (val: number) => (testWidth = val));
});
expect(testWidth).toBe(0);
// @ts-ignore
window.innerWidth = 1024;
window.dispatchEvent(new Event('resize'));
await component.vm.$nextTick();
expect(testWidth).toBe(1024);
});
it('Adds / removes resize event handler on mount / unmount', async () => {
const map: any = {};
window.addEventListener = jest.fn((event, cb) => {
map[event] = cb;
});
window.removeEventListener = jest.fn((event, cb) => {
delete map[event];
});
const component = mountComposition(() => {
const { width } = useWindowSize();
});
expect(map.resize).toBeTruthy();
component.destroy();
expect(map.keydown).toBe(undefined);
});
});

View File

@@ -0,0 +1,30 @@
import { onMounted, onUnmounted, ref, onBeforeMount, onBeforeUnmount } from '@vue/composition-api';
import { throttle } from 'lodash';
type WindowSizeOptions = {
throttle: number;
};
export default function useWindowSize(options: WindowSizeOptions = { throttle: 100 }) {
const width = ref(0);
const height = ref(0);
function setSize() {
width.value = window.innerWidth;
height.value = window.innerHeight;
}
const onResize = throttle(setSize, options.throttle);
onBeforeMount(setSize);
onMounted(() => {
window.addEventListener('resize', onResize, { passive: true });
});
onUnmounted(() => {
window.removeEventListener('resize', onResize);
});
return { width, height };
}

View File

@@ -0,0 +1,15 @@
import parseCSSVar from './parse-css-var';
describe('Utils / parseCSSVar', () => {
it('Wraps CSS variables in var()', () => {
const result = parseCSSVar('--red');
expect(result).toBe('var(--red)');
});
it('Passes through regular CSS', () => {
const result = parseCSSVar('#abcabc');
expect(result).toBe('#abcabc');
});
});

View File

@@ -0,0 +1,6 @@
// If the passed color string starts with --, it will be returned wrapped in `var()`, so it can be
// used in CSS.
export default function parseCSSVar(color: string): string {
if (color.startsWith('--')) return `var(${color})`;
return color;
}