mirror of
https://github.com/directus/directus.git
synced 2026-01-27 00:48:16 -05:00
Migrate existing (finished) base components
This commit is contained in:
4
src/components/v-avatar/index.ts
Normal file
4
src/components/v-avatar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VAvatar from './v-avatar.vue';
|
||||
|
||||
export { VAvatar };
|
||||
export default VAvatar;
|
||||
26
src/components/v-avatar/v-avatar.readme.md
Normal file
26
src/components/v-avatar/v-avatar.readme.md
Normal 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 |
|
||||
|
||||
174
src/components/v-avatar/v-avatar.story.ts
Normal file
174
src/components/v-avatar/v-avatar.story.ts
Normal 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>
|
||||
`
|
||||
});
|
||||
78
src/components/v-avatar/v-avatar.test.ts
Normal file
78
src/components/v-avatar/v-avatar.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
118
src/components/v-avatar/v-avatar.vue
Normal file
118
src/components/v-avatar/v-avatar.vue
Normal 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>
|
||||
4
src/components/v-button/index.ts
Normal file
4
src/components/v-button/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VButton from './v-button.vue';
|
||||
|
||||
export { VButton };
|
||||
export default VButton;
|
||||
86
src/components/v-button/v-button.readme.md
Normal file
86
src/components/v-button/v-button.readme.md
Normal 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 |
|
||||
279
src/components/v-button/v-button.story.ts
Normal file
279
src/components/v-button/v-button.story.ts
Normal 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>`
|
||||
});
|
||||
157
src/components/v-button/v-button.test.ts
Normal file
157
src/components/v-button/v-button.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
236
src/components/v-button/v-button.vue
Normal file
236
src/components/v-button/v-button.vue
Normal 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>
|
||||
4
src/components/v-checkbox/index.ts
Normal file
4
src/components/v-checkbox/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VCheckbox from './v-checkbox.vue';
|
||||
|
||||
export { VCheckbox };
|
||||
export default VCheckbox;
|
||||
92
src/components/v-checkbox/v-checkbox.readme.md
Normal file
92
src/components/v-checkbox/v-checkbox.readme.md
Normal 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. |
|
||||
120
src/components/v-checkbox/v-checkbox.story.ts
Normal file
120
src/components/v-checkbox/v-checkbox.story.ts
Normal 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>
|
||||
`
|
||||
});
|
||||
149
src/components/v-checkbox/v-checkbox.test.ts
Normal file
149
src/components/v-checkbox/v-checkbox.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
125
src/components/v-checkbox/v-checkbox.vue
Normal file
125
src/components/v-checkbox/v-checkbox.vue
Normal 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>
|
||||
4
src/components/v-hover/index.ts
Normal file
4
src/components/v-hover/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VHover from './v-hover.vue';
|
||||
|
||||
export { VHover };
|
||||
export default VHover;
|
||||
33
src/components/v-hover/v-hover.readme.md
Normal file
33
src/components/v-hover/v-hover.readme.md
Normal 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
|
||||
33
src/components/v-hover/v-hover.story.ts
Normal file
33
src/components/v-hover/v-hover.story.ts
Normal 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>
|
||||
`;
|
||||
84
src/components/v-hover/v-hover.test.ts
Normal file
84
src/components/v-hover/v-hover.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
src/components/v-hover/v-hover.vue
Normal file
51
src/components/v-hover/v-hover.vue
Normal 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>
|
||||
18
src/components/v-icon/custom-icons/box.vue
Normal file
18
src/components/v-icon/custom-icons/box.vue
Normal 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>
|
||||
4
src/components/v-icon/index.ts
Normal file
4
src/components/v-icon/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VIcon from './v-icon.vue';
|
||||
|
||||
export { VIcon };
|
||||
export default VIcon;
|
||||
46
src/components/v-icon/v-icon.readme.md
Normal file
46
src/components/v-icon/v-icon.readme.md
Normal 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` |
|
||||
87
src/components/v-icon/v-icon.story.ts
Normal file
87
src/components/v-icon/v-icon.story.ts
Normal 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" />
|
||||
`
|
||||
});
|
||||
151
src/components/v-icon/v-icon.test.ts
Normal file
151
src/components/v-icon/v-icon.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
196
src/components/v-icon/v-icon.vue
Normal file
196
src/components/v-icon/v-icon.vue
Normal 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>
|
||||
4
src/components/v-input/index.ts
Normal file
4
src/components/v-input/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VInput from './v-input.vue';
|
||||
|
||||
export { VInput };
|
||||
export default VInput;
|
||||
45
src/components/v-input/v-input.readme.md
Normal file
45
src/components/v-input/v-input.readme.md
Normal 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`.
|
||||
89
src/components/v-input/v-input.story.ts
Normal file
89
src/components/v-input/v-input.story.ts
Normal 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>
|
||||
`
|
||||
});
|
||||
71
src/components/v-input/v-input.test.ts
Normal file
71
src/components/v-input/v-input.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
147
src/components/v-input/v-input.vue
Normal file
147
src/components/v-input/v-input.vue
Normal 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>
|
||||
4
src/components/v-overlay/index.ts
Normal file
4
src/components/v-overlay/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VOverlay from './v-overlay.vue';
|
||||
|
||||
export { VOverlay };
|
||||
export default VOverlay;
|
||||
27
src/components/v-overlay/v-overlay.readme.md
Normal file
27
src/components/v-overlay/v-overlay.readme.md
Normal 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.
|
||||
90
src/components/v-overlay/v-overlay.story.ts
Normal file
90
src/components/v-overlay/v-overlay.story.ts
Normal 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>
|
||||
`
|
||||
});
|
||||
62
src/components/v-overlay/v-overlay.test.ts
Normal file
62
src/components/v-overlay/v-overlay.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
98
src/components/v-overlay/v-overlay.vue
Normal file
98
src/components/v-overlay/v-overlay.vue
Normal 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>
|
||||
4
src/components/v-progress/linear/index.ts
Normal file
4
src/components/v-progress/linear/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VProgressLinear from './v-progress-linear.vue';
|
||||
|
||||
export { VProgressLinear };
|
||||
export default VProgressLinear;
|
||||
42
src/components/v-progress/linear/v-progress-linear.readme.md
Normal file
42
src/components/v-progress/linear/v-progress-linear.readme.md
Normal 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.
|
||||
72
src/components/v-progress/linear/v-progress-linear.test.ts
Normal file
72
src/components/v-progress/linear/v-progress-linear.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
146
src/components/v-progress/linear/v-progress-linear.vue
Normal file
146
src/components/v-progress/linear/v-progress-linear.vue
Normal 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>
|
||||
75
src/components/v-progress/linear/v-progress.linear.story.ts
Normal file
75
src/components/v-progress/linear/v-progress.linear.story.ts
Normal 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>
|
||||
`;
|
||||
4
src/components/v-sheet/index.ts
Normal file
4
src/components/v-sheet/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VSheet from './v-sheet.vue';
|
||||
|
||||
export { VSheet };
|
||||
export default VSheet;
|
||||
23
src/components/v-sheet/v-sheet.readme.md
Normal file
23
src/components/v-sheet/v-sheet.readme.md
Normal 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>
|
||||
```
|
||||
|
||||
88
src/components/v-sheet/v-sheet.story.ts
Normal file
88
src/components/v-sheet/v-sheet.story.ts
Normal 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>
|
||||
`;
|
||||
38
src/components/v-sheet/v-sheet.test.ts
Normal file
38
src/components/v-sheet/v-sheet.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
113
src/components/v-sheet/v-sheet.vue
Normal file
113
src/components/v-sheet/v-sheet.vue
Normal 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>
|
||||
4
src/components/v-slider/index.ts
Normal file
4
src/components/v-slider/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VSlider from './v-slider.vue';
|
||||
|
||||
export { VSlider };
|
||||
export default VSlider;
|
||||
62
src/components/v-slider/v-slider.readme.md
Normal file
62
src/components/v-slider/v-slider.readme.md
Normal 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 }` |
|
||||
248
src/components/v-slider/v-slider.story.ts
Normal file
248
src/components/v-slider/v-slider.story.ts
Normal 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>
|
||||
`
|
||||
});
|
||||
72
src/components/v-slider/v-slider.test.ts
Normal file
72
src/components/v-slider/v-slider.test.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
255
src/components/v-slider/v-slider.vue
Normal file
255
src/components/v-slider/v-slider.vue
Normal 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>
|
||||
4
src/components/v-spinner/index.ts
Normal file
4
src/components/v-spinner/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VSpinner from './v-spinner.vue';
|
||||
|
||||
export { VSpinner };
|
||||
export default VSpinner;
|
||||
62
src/components/v-spinner/v-spinner.readme.md
Normal file
62
src/components/v-spinner/v-spinner.readme.md
Normal 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` |
|
||||
94
src/components/v-spinner/v-spinner.story.ts
Normal file
94
src/components/v-spinner/v-spinner.story.ts
Normal 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>
|
||||
`;
|
||||
99
src/components/v-spinner/v-spinner.test.ts
Normal file
99
src/components/v-spinner/v-spinner.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/components/v-spinner/v-spinner.vue
Normal file
137
src/components/v-spinner/v-spinner.vue
Normal 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>
|
||||
4
src/components/v-switch/index.ts
Normal file
4
src/components/v-switch/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VSwitch from './v-switch.vue';
|
||||
|
||||
export { VSwitch };
|
||||
export default VSwitch;
|
||||
67
src/components/v-switch/v-switch.readme.md
Normal file
67
src/components/v-switch/v-switch.readme.md
Normal 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. |
|
||||
104
src/components/v-switch/v-switch.story.ts
Normal file
104
src/components/v-switch/v-switch.story.ts
Normal 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>
|
||||
`
|
||||
});
|
||||
110
src/components/v-switch/v-switch.test.ts
Normal file
110
src/components/v-switch/v-switch.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
163
src/components/v-switch/v-switch.vue
Normal file
163
src/components/v-switch/v-switch.vue
Normal 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>
|
||||
416
src/components/v-table/_table-header.test.ts
Normal file
416
src/components/v-table/_table-header.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
298
src/components/v-table/_table-header.vue
Normal file
298
src/components/v-table/_table-header.vue
Normal 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>
|
||||
123
src/components/v-table/_table-row.test.ts
Normal file
123
src/components/v-table/_table-row.test.ts
Normal 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
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
117
src/components/v-table/_table-row.vue
Normal file
117
src/components/v-table/_table-row.vue
Normal 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>
|
||||
4
src/components/v-table/index.ts
Normal file
4
src/components/v-table/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VTable from './v-table.vue';
|
||||
|
||||
export { VTable };
|
||||
export default VTable;
|
||||
21
src/components/v-table/types.ts
Normal file
21
src/components/v-table/types.ts
Normal 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;
|
||||
};
|
||||
141
src/components/v-table/v-table.readme.md
Normal file
141
src/components/v-table/v-table.readme.md
Normal 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 |
|
||||
1054
src/components/v-table/v-table.story.ts
Normal file
1054
src/components/v-table/v-table.story.ts
Normal file
File diff suppressed because it is too large
Load Diff
754
src/components/v-table/v-table.test.ts
Normal file
754
src/components/v-table/v-table.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
346
src/components/v-table/v-table.vue
Normal file
346
src/components/v-table/v-table.vue
Normal 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>
|
||||
54
src/compositions/event-listener.test.ts
Normal file
54
src/compositions/event-listener.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
src/compositions/event-listener.ts
Normal file
18
src/compositions/event-listener.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
47
src/compositions/window-size.test.ts
Normal file
47
src/compositions/window-size.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
src/compositions/window-size.ts
Normal file
30
src/compositions/window-size.ts
Normal 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 };
|
||||
}
|
||||
15
src/utils/parse-css-var.test.ts
Normal file
15
src/utils/parse-css-var.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
6
src/utils/parse-css-var.ts
Normal file
6
src/utils/parse-css-var.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user