From 55e56e30ecb60a19e7243bc1dde951bfb1a06a67 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Wed, 5 Feb 2020 15:11:40 -0500 Subject: [PATCH] Migrate existing (finished) base components --- src/components/v-avatar/index.ts | 4 + src/components/v-avatar/v-avatar.readme.md | 26 + src/components/v-avatar/v-avatar.story.ts | 174 +++ src/components/v-avatar/v-avatar.test.ts | 78 ++ src/components/v-avatar/v-avatar.vue | 118 ++ src/components/v-button/index.ts | 4 + src/components/v-button/v-button.readme.md | 86 ++ src/components/v-button/v-button.story.ts | 279 +++++ src/components/v-button/v-button.test.ts | 157 +++ src/components/v-button/v-button.vue | 236 ++++ src/components/v-checkbox/index.ts | 4 + .../v-checkbox/v-checkbox.readme.md | 92 ++ src/components/v-checkbox/v-checkbox.story.ts | 120 ++ src/components/v-checkbox/v-checkbox.test.ts | 149 +++ src/components/v-checkbox/v-checkbox.vue | 125 ++ src/components/v-hover/index.ts | 4 + src/components/v-hover/v-hover.readme.md | 33 + src/components/v-hover/v-hover.story.ts | 33 + src/components/v-hover/v-hover.test.ts | 84 ++ src/components/v-hover/v-hover.vue | 51 + src/components/v-icon/custom-icons/box.vue | 18 + src/components/v-icon/index.ts | 4 + src/components/v-icon/v-icon.readme.md | 46 + src/components/v-icon/v-icon.story.ts | 87 ++ src/components/v-icon/v-icon.test.ts | 151 +++ src/components/v-icon/v-icon.vue | 196 +++ src/components/v-input/index.ts | 4 + src/components/v-input/v-input.readme.md | 45 + src/components/v-input/v-input.story.ts | 89 ++ src/components/v-input/v-input.test.ts | 71 ++ src/components/v-input/v-input.vue | 147 +++ src/components/v-overlay/index.ts | 4 + src/components/v-overlay/v-overlay.readme.md | 27 + src/components/v-overlay/v-overlay.story.ts | 90 ++ src/components/v-overlay/v-overlay.test.ts | 62 + src/components/v-overlay/v-overlay.vue | 98 ++ src/components/v-progress/linear/index.ts | 4 + .../linear/v-progress-linear.readme.md | 42 + .../linear/v-progress-linear.test.ts | 72 ++ .../v-progress/linear/v-progress-linear.vue | 146 +++ .../linear/v-progress.linear.story.ts | 75 ++ src/components/v-sheet/index.ts | 4 + src/components/v-sheet/v-sheet.readme.md | 23 + src/components/v-sheet/v-sheet.story.ts | 88 ++ src/components/v-sheet/v-sheet.test.ts | 38 + src/components/v-sheet/v-sheet.vue | 113 ++ src/components/v-slider/index.ts | 4 + src/components/v-slider/v-slider.readme.md | 62 + src/components/v-slider/v-slider.story.ts | 248 ++++ src/components/v-slider/v-slider.test.ts | 72 ++ src/components/v-slider/v-slider.vue | 255 ++++ src/components/v-spinner/index.ts | 4 + src/components/v-spinner/v-spinner.readme.md | 62 + src/components/v-spinner/v-spinner.story.ts | 94 ++ src/components/v-spinner/v-spinner.test.ts | 99 ++ src/components/v-spinner/v-spinner.vue | 137 +++ src/components/v-switch/index.ts | 4 + src/components/v-switch/v-switch.readme.md | 67 ++ src/components/v-switch/v-switch.story.ts | 104 ++ src/components/v-switch/v-switch.test.ts | 110 ++ src/components/v-switch/v-switch.vue | 163 +++ src/components/v-table/_table-header.test.ts | 416 +++++++ src/components/v-table/_table-header.vue | 298 +++++ src/components/v-table/_table-row.test.ts | 123 ++ src/components/v-table/_table-row.vue | 117 ++ src/components/v-table/index.ts | 4 + src/components/v-table/types.ts | 21 + src/components/v-table/v-table.readme.md | 141 +++ src/components/v-table/v-table.story.ts | 1054 +++++++++++++++++ src/components/v-table/v-table.test.ts | 754 ++++++++++++ src/components/v-table/v-table.vue | 346 ++++++ src/compositions/event-listener.test.ts | 54 + src/compositions/event-listener.ts | 18 + src/compositions/window-size.test.ts | 47 + src/compositions/window-size.ts | 30 + src/utils/parse-css-var.test.ts | 15 + src/utils/parse-css-var.ts | 6 + 77 files changed, 8530 insertions(+) create mode 100644 src/components/v-avatar/index.ts create mode 100644 src/components/v-avatar/v-avatar.readme.md create mode 100644 src/components/v-avatar/v-avatar.story.ts create mode 100644 src/components/v-avatar/v-avatar.test.ts create mode 100644 src/components/v-avatar/v-avatar.vue create mode 100644 src/components/v-button/index.ts create mode 100644 src/components/v-button/v-button.readme.md create mode 100644 src/components/v-button/v-button.story.ts create mode 100644 src/components/v-button/v-button.test.ts create mode 100644 src/components/v-button/v-button.vue create mode 100644 src/components/v-checkbox/index.ts create mode 100644 src/components/v-checkbox/v-checkbox.readme.md create mode 100644 src/components/v-checkbox/v-checkbox.story.ts create mode 100644 src/components/v-checkbox/v-checkbox.test.ts create mode 100644 src/components/v-checkbox/v-checkbox.vue create mode 100644 src/components/v-hover/index.ts create mode 100644 src/components/v-hover/v-hover.readme.md create mode 100644 src/components/v-hover/v-hover.story.ts create mode 100644 src/components/v-hover/v-hover.test.ts create mode 100644 src/components/v-hover/v-hover.vue create mode 100644 src/components/v-icon/custom-icons/box.vue create mode 100644 src/components/v-icon/index.ts create mode 100644 src/components/v-icon/v-icon.readme.md create mode 100644 src/components/v-icon/v-icon.story.ts create mode 100644 src/components/v-icon/v-icon.test.ts create mode 100644 src/components/v-icon/v-icon.vue create mode 100644 src/components/v-input/index.ts create mode 100644 src/components/v-input/v-input.readme.md create mode 100644 src/components/v-input/v-input.story.ts create mode 100644 src/components/v-input/v-input.test.ts create mode 100644 src/components/v-input/v-input.vue create mode 100644 src/components/v-overlay/index.ts create mode 100644 src/components/v-overlay/v-overlay.readme.md create mode 100644 src/components/v-overlay/v-overlay.story.ts create mode 100644 src/components/v-overlay/v-overlay.test.ts create mode 100644 src/components/v-overlay/v-overlay.vue create mode 100644 src/components/v-progress/linear/index.ts create mode 100644 src/components/v-progress/linear/v-progress-linear.readme.md create mode 100644 src/components/v-progress/linear/v-progress-linear.test.ts create mode 100644 src/components/v-progress/linear/v-progress-linear.vue create mode 100644 src/components/v-progress/linear/v-progress.linear.story.ts create mode 100644 src/components/v-sheet/index.ts create mode 100644 src/components/v-sheet/v-sheet.readme.md create mode 100644 src/components/v-sheet/v-sheet.story.ts create mode 100644 src/components/v-sheet/v-sheet.test.ts create mode 100644 src/components/v-sheet/v-sheet.vue create mode 100644 src/components/v-slider/index.ts create mode 100644 src/components/v-slider/v-slider.readme.md create mode 100644 src/components/v-slider/v-slider.story.ts create mode 100644 src/components/v-slider/v-slider.test.ts create mode 100644 src/components/v-slider/v-slider.vue create mode 100644 src/components/v-spinner/index.ts create mode 100644 src/components/v-spinner/v-spinner.readme.md create mode 100644 src/components/v-spinner/v-spinner.story.ts create mode 100644 src/components/v-spinner/v-spinner.test.ts create mode 100644 src/components/v-spinner/v-spinner.vue create mode 100644 src/components/v-switch/index.ts create mode 100644 src/components/v-switch/v-switch.readme.md create mode 100644 src/components/v-switch/v-switch.story.ts create mode 100644 src/components/v-switch/v-switch.test.ts create mode 100644 src/components/v-switch/v-switch.vue create mode 100644 src/components/v-table/_table-header.test.ts create mode 100644 src/components/v-table/_table-header.vue create mode 100644 src/components/v-table/_table-row.test.ts create mode 100644 src/components/v-table/_table-row.vue create mode 100644 src/components/v-table/index.ts create mode 100644 src/components/v-table/types.ts create mode 100644 src/components/v-table/v-table.readme.md create mode 100644 src/components/v-table/v-table.story.ts create mode 100644 src/components/v-table/v-table.test.ts create mode 100644 src/components/v-table/v-table.vue create mode 100644 src/compositions/event-listener.test.ts create mode 100644 src/compositions/event-listener.ts create mode 100644 src/compositions/window-size.test.ts create mode 100644 src/compositions/window-size.ts create mode 100644 src/utils/parse-css-var.test.ts create mode 100644 src/utils/parse-css-var.ts diff --git a/src/components/v-avatar/index.ts b/src/components/v-avatar/index.ts new file mode 100644 index 0000000000..573eafcb91 --- /dev/null +++ b/src/components/v-avatar/index.ts @@ -0,0 +1,4 @@ +import VAvatar from './v-avatar.vue'; + +export { VAvatar }; +export default VAvatar; diff --git a/src/components/v-avatar/v-avatar.readme.md b/src/components/v-avatar/v-avatar.readme.md new file mode 100644 index 0000000000..4d35ba1c81 --- /dev/null +++ b/src/components/v-avatar/v-avatar.readme.md @@ -0,0 +1,26 @@ +# Avatar + +```html +RVZ + + + + + + + + +``` + +## 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 | + diff --git a/src/components/v-avatar/v-avatar.story.ts b/src/components/v-avatar/v-avatar.story.ts new file mode 100644 index 0000000000..9453e0adb8 --- /dev/null +++ b/src/components/v-avatar/v-avatar.story.ts @@ -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: ` + {{ text }}` +}); + +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: ` + + + ` +}); + +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: ` + + + ` +}); + +export const sizes = () => ({ + template: ` +
+ + + + + + + + + + + + + + + +
+ ` +}); diff --git a/src/components/v-avatar/v-avatar.test.ts b/src/components/v-avatar/v-avatar.test.ts new file mode 100644 index 0000000000..b8b81a8692 --- /dev/null +++ b/src/components/v-avatar/v-avatar.test.ts @@ -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; + + 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'); + }); + }); +}); diff --git a/src/components/v-avatar/v-avatar.vue b/src/components/v-avatar/v-avatar.vue new file mode 100644 index 0000000000..b6209857e3 --- /dev/null +++ b/src/components/v-avatar/v-avatar.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/components/v-button/index.ts b/src/components/v-button/index.ts new file mode 100644 index 0000000000..c649b63005 --- /dev/null +++ b/src/components/v-button/index.ts @@ -0,0 +1,4 @@ +import VButton from './v-button.vue'; + +export { VButton }; +export default VButton; diff --git a/src/components/v-button/v-button.readme.md b/src/components/v-button/v-button.readme.md new file mode 100644 index 0000000000..5b8f005bce --- /dev/null +++ b/src/components/v-button/v-button.readme.md @@ -0,0 +1,86 @@ +# Button + +```html +Click me! +``` + +## 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 +Click me! +``` + +## 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 + + Click me + +``` + +## Events + +The only event that can be added to the button is the `click` event: + +```html +Hello! +``` + +## 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 + + + +``` + +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 | diff --git a/src/components/v-button/v-button.story.ts b/src/components/v-button/v-button.story.ts new file mode 100644 index 0000000000..b7877af8a3 --- /dev/null +++ b/src/components/v-button/v-button.story.ts @@ -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: ` + + {{ text }} + + ` +}); + +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: ` + + + + ` +}); + +export const sizes = () => ` +
+ Extra small + Small + Default + Large + Extra Large +
+`; + +export const colors = () => ` +
+ + Delete + + + Save + + + Warn + + + Hover + + + Transparent + +
+`; + +export const customLoading = () => ({ + props: { + loading: { + default: boolean('Loading', true) + } + }, + template: ` + + Hello, World! + + ` +}); diff --git a/src/components/v-button/v-button.test.ts b/src/components/v-button/v-button.test.ts new file mode 100644 index 0000000000..e15f9a6d4f --- /dev/null +++ b/src/components/v-button/v-button.test.ts @@ -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'); + }); + }); +}); diff --git a/src/components/v-button/v-button.vue b/src/components/v-button/v-button.vue new file mode 100644 index 0000000000..f5100cbfc8 --- /dev/null +++ b/src/components/v-button/v-button.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/src/components/v-checkbox/index.ts b/src/components/v-checkbox/index.ts new file mode 100644 index 0000000000..b6c9fbb95c --- /dev/null +++ b/src/components/v-checkbox/index.ts @@ -0,0 +1,4 @@ +import VCheckbox from './v-checkbox.vue'; + +export { VCheckbox }; +export default VCheckbox; diff --git a/src/components/v-checkbox/v-checkbox.readme.md b/src/components/v-checkbox/v-checkbox.readme.md new file mode 100644 index 0000000000..af66fab6d2 --- /dev/null +++ b/src/components/v-checkbox/v-checkbox.readme.md @@ -0,0 +1,92 @@ +# Checkbox + +## Basic usage + +```html + +``` + +## Colors + +The checkbox component accepts any CSS color value, or variable name: + +```html + + + + +``` + +## Boolean vs arrays + +Just as with checkboxes, you can use `v-model` with both an array and a boolean: + + +```html + + + +``` + +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 + + + +``` + +If you can't, you should listen to the `update:indeterminate` event and respond to that: + +```html + +``` + +## 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. | diff --git a/src/components/v-checkbox/v-checkbox.story.ts b/src/components/v-checkbox/v-checkbox.story.ts new file mode 100644 index 0000000000..e784b1854a --- /dev/null +++ b/src/components/v-checkbox/v-checkbox.story.ts @@ -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: ` +
+ +
{{checked}}
+
+ ` +}); + +export const arrayState = () => ({ + methods: { + onChange: action('change') + }, + data() { + return { + options: ['html', 'css'] + }; + }, + template: ` +
+ + + +
{{options}}
+
+ ` +}); + +export const disabled = () => + `
`; + +export const indeterminate = () => ({ + data() { + return { + indeterminate: true, + value: null + }; + }, + template: `
+ +
+indeterminate: {{indeterminate}}
+value: {{value}}
+
+
` +}); + +export const colors = () => ({ + methods: { + onChange: action('change') + }, + data() { + return { + options: ['red', 'yellow', 'custom'] + }; + }, + props: { + customColor: { + default: color('Custom color', '#4CAF50') + } + }, + template: ` +
+ + + + +
+ ` +}); + +export const htmlLabel = () => ({ + methods: { + onChange: action('change') + }, + data() { + return { + checked: true + }; + }, + template: ` + + + + ` +}); diff --git a/src/components/v-checkbox/v-checkbox.test.ts b/src/components/v-checkbox/v-checkbox.test.ts new file mode 100644 index 0000000000..8358040a0e --- /dev/null +++ b/src/components/v-checkbox/v-checkbox.test.ts @@ -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]); + }); +}); diff --git a/src/components/v-checkbox/v-checkbox.vue b/src/components/v-checkbox/v-checkbox.vue new file mode 100644 index 0000000000..9d70c9483e --- /dev/null +++ b/src/components/v-checkbox/v-checkbox.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/v-hover/index.ts b/src/components/v-hover/index.ts new file mode 100644 index 0000000000..aca02b1e16 --- /dev/null +++ b/src/components/v-hover/index.ts @@ -0,0 +1,4 @@ +import VHover from './v-hover.vue'; + +export { VHover }; +export default VHover; diff --git a/src/components/v-hover/v-hover.readme.md b/src/components/v-hover/v-hover.readme.md new file mode 100644 index 0000000000..f2d66fe43f --- /dev/null +++ b/src/components/v-hover/v-hover.readme.md @@ -0,0 +1,33 @@ +# Hover (util) + +```html + + + +``` + +## 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 + + + +``` + +## 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 diff --git a/src/components/v-hover/v-hover.story.ts b/src/components/v-hover/v-hover.story.ts new file mode 100644 index 0000000000..8c4553d858 --- /dev/null +++ b/src/components/v-hover/v-hover.story.ts @@ -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 = () => ` + + + +`; + +export const customMarkup = () => ` + + + + +`; diff --git a/src/components/v-hover/v-hover.test.ts b/src/components/v-hover/v-hover.test.ts new file mode 100644 index 0000000000..eec55c2b7f --- /dev/null +++ b/src/components/v-hover/v-hover.test.ts @@ -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 { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +describe('Hover', () => { + let component: Wrapper; + + 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); + }); +}); diff --git a/src/components/v-hover/v-hover.vue b/src/components/v-hover/v-hover.vue new file mode 100644 index 0000000000..63da1afb2f --- /dev/null +++ b/src/components/v-hover/v-hover.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/v-icon/custom-icons/box.vue b/src/components/v-icon/custom-icons/box.vue new file mode 100644 index 0000000000..245663d254 --- /dev/null +++ b/src/components/v-icon/custom-icons/box.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/v-icon/index.ts b/src/components/v-icon/index.ts new file mode 100644 index 0000000000..8fbaaead0e --- /dev/null +++ b/src/components/v-icon/index.ts @@ -0,0 +1,4 @@ +import VIcon from './v-icon.vue'; + +export { VIcon }; +export default VIcon; diff --git a/src/components/v-icon/v-icon.readme.md b/src/components/v-icon/v-icon.readme.md new file mode 100644 index 0000000000..1fb00fb6db --- /dev/null +++ b/src/components/v-icon/v-icon.readme.md @@ -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` | diff --git a/src/components/v-icon/v-icon.story.ts b/src/components/v-icon/v-icon.story.ts new file mode 100644 index 0000000000..acfa04a119 --- /dev/null +++ b/src/components/v-icon/v-icon.story.ts @@ -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: ` + + ` +}); + +export const superscript = () => `Title`; + +export const sizesAndColors = () => ` +
+ + + + + + +
+`; + +export const withClickEvent = () => ({ + methods: { + click: action('click') + }, + template: ` + +` +}); diff --git a/src/components/v-icon/v-icon.test.ts b/src/components/v-icon/v-icon.test.ts new file mode 100644 index 0000000000..b52923d6ef --- /dev/null +++ b/src/components/v-icon/v-icon.test.ts @@ -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; + + 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( + 'person' + ); + }); + + it('Renders custom icons as inline ', 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(); + }); +}); diff --git a/src/components/v-icon/v-icon.vue b/src/components/v-icon/v-icon.vue new file mode 100644 index 0000000000..aca867b616 --- /dev/null +++ b/src/components/v-icon/v-icon.vue @@ -0,0 +1,196 @@ + + + {{ name }} + + + + + + diff --git a/src/components/v-input/index.ts b/src/components/v-input/index.ts new file mode 100644 index 0000000000..959cd6417e --- /dev/null +++ b/src/components/v-input/index.ts @@ -0,0 +1,4 @@ +import VInput from './v-input.vue'; + +export { VInput }; +export default VInput; diff --git a/src/components/v-input/v-input.readme.md b/src/components/v-input/v-input.readme.md new file mode 100644 index 0000000000..0acc03dd35 --- /dev/null +++ b/src/components/v-input/v-input.readme.md @@ -0,0 +1,45 @@ +# Input + +```html + +``` + +## Attributes & Events + +The HTML `` 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 `` 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`. diff --git a/src/components/v-input/v-input.story.ts b/src/components/v-input/v-input.story.ts new file mode 100644 index 0000000000..05f0bfaa31 --- /dev/null +++ b/src/components/v-input/v-input.story.ts @@ -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: ` +
+ +
+value: {{ value }}
+
+
+` +}); + +export const monospace = () => ({ + data() { + return { + value: '' + }; + }, + template: ` +
+ +
+` +}); + +export const disabled = () => ``; + +export const fullWidth = () => ` + +`; + +export const prefixSuffix = () => ` +
+ + +
+`; + +export const withSlots = () => ({ + data() { + return { + value: '' + }; + }, + template: ` +
+ + + + + + + + + + + + + + + + + + +
+ ` +}); diff --git a/src/components/v-input/v-input.test.ts b/src/components/v-input/v-input.test.ts new file mode 100644 index 0000000000..6d1bb3d436 --- /dev/null +++ b/src/components/v-input/v-input.test.ts @@ -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; + + beforeEach(() => { + component = mount(VInput, { localVue }); + }); + + it('Renders content in the correct slots', async () => { + component = mount(VInput, { + localVue, + slots: { + 'prepend-outer': '
prepend-outer
', + prepend: '
prepend
', + append: '
append
', + 'append-outer': '
append-outer
' + } + }); + + expect(component.find('.v-input > .prepend-outer > div ').html()).toBe( + '
prepend-outer
' + ); + expect(component.find('.v-input > .append-outer > div ').html()).toBe( + '
append-outer
' + ); + expect(component.find('.v-input > .input > .prepend > div ').html()).toBe( + '
prepend
' + ); + expect(component.find('.v-input > .input > .append > div ').html()).toBe( + '
append
' + ); + }); + + it('Renders prefix / suffix', async () => { + component.setProps({ + prefix: 'Prefix', + suffix: 'Suffix' + }); + + await component.vm.$nextTick(); + + expect(component.find('.input .prefix').html()).toBe('Prefix'); + expect(component.find('.input .suffix').html()).toBe('Suffix'); + }); + + 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']); + }); +}); diff --git a/src/components/v-input/v-input.vue b/src/components/v-input/v-input.vue new file mode 100644 index 0000000000..fe2ff96c4e --- /dev/null +++ b/src/components/v-input/v-input.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/components/v-overlay/index.ts b/src/components/v-overlay/index.ts new file mode 100644 index 0000000000..489e1aa2e4 --- /dev/null +++ b/src/components/v-overlay/index.ts @@ -0,0 +1,4 @@ +import VOverlay from './v-overlay.vue'; + +export { VOverlay }; +export default VOverlay; diff --git a/src/components/v-overlay/v-overlay.readme.md b/src/components/v-overlay/v-overlay.readme.md new file mode 100644 index 0000000000..e6196e5e35 --- /dev/null +++ b/src/components/v-overlay/v-overlay.readme.md @@ -0,0 +1,27 @@ +# Overlay + +```html + + Close 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. diff --git a/src/components/v-overlay/v-overlay.story.ts b/src/components/v-overlay/v-overlay.story.ts new file mode 100644 index 0000000000..5f7e76a938 --- /dev/null +++ b/src/components/v-overlay/v-overlay.story.ts @@ -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: ` +
+ Show overlay + + + Close overlay + +
+ ` +}); + +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: ` +
+ Show overlay + + +
+ ` +}); diff --git a/src/components/v-overlay/v-overlay.test.ts b/src/components/v-overlay/v-overlay.test.ts new file mode 100644 index 0000000000..7c9ba8ed47 --- /dev/null +++ b/src/components/v-overlay/v-overlay.test.ts @@ -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; + + 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(); + }); +}); diff --git a/src/components/v-overlay/v-overlay.vue b/src/components/v-overlay/v-overlay.vue new file mode 100644 index 0000000000..0dc748a5c8 --- /dev/null +++ b/src/components/v-overlay/v-overlay.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/components/v-progress/linear/index.ts b/src/components/v-progress/linear/index.ts new file mode 100644 index 0000000000..348de049d4 --- /dev/null +++ b/src/components/v-progress/linear/index.ts @@ -0,0 +1,4 @@ +import VProgressLinear from './v-progress-linear.vue'; + +export { VProgressLinear }; +export default VProgressLinear; diff --git a/src/components/v-progress/linear/v-progress-linear.readme.md b/src/components/v-progress/linear/v-progress-linear.readme.md new file mode 100644 index 0000000000..29738d31e7 --- /dev/null +++ b/src/components/v-progress/linear/v-progress-linear.readme.md @@ -0,0 +1,42 @@ +# Progress (Linear) + +```html + +``` + +## 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 + + + + +``` + +## 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. diff --git a/src/components/v-progress/linear/v-progress-linear.test.ts b/src/components/v-progress/linear/v-progress-linear.test.ts new file mode 100644 index 0000000000..27576bbbc7 --- /dev/null +++ b/src/components/v-progress/linear/v-progress-linear.test.ts @@ -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; + + 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']); + }); +}); diff --git a/src/components/v-progress/linear/v-progress-linear.vue b/src/components/v-progress/linear/v-progress-linear.vue new file mode 100644 index 0000000000..db045d98a4 --- /dev/null +++ b/src/components/v-progress/linear/v-progress-linear.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/src/components/v-progress/linear/v-progress.linear.story.ts b/src/components/v-progress/linear/v-progress.linear.story.ts new file mode 100644 index 0000000000..5b97e463e6 --- /dev/null +++ b/src/components/v-progress/linear/v-progress.linear.story.ts @@ -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: ` + ` +}); + +export const withSlot = () => ` +25% +`; diff --git a/src/components/v-sheet/index.ts b/src/components/v-sheet/index.ts new file mode 100644 index 0000000000..d8f1b19758 --- /dev/null +++ b/src/components/v-sheet/index.ts @@ -0,0 +1,4 @@ +import VSheet from './v-sheet.vue'; + +export { VSheet }; +export default VSheet; diff --git a/src/components/v-sheet/v-sheet.readme.md b/src/components/v-sheet/v-sheet.readme.md new file mode 100644 index 0000000000..987ac9436d --- /dev/null +++ b/src/components/v-sheet/v-sheet.readme.md @@ -0,0 +1,23 @@ +# Sheet + +```html + +``` + +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 + + + + +``` + diff --git a/src/components/v-sheet/v-sheet.story.ts b/src/components/v-sheet/v-sheet.story.ts new file mode 100644 index 0000000000..8f2ed550d3 --- /dev/null +++ b/src/components/v-sheet/v-sheet.story.ts @@ -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: ` + {{ text }}` +}); + +export const colorsSizes = () => ` +
+ + + + + +
+`; diff --git a/src/components/v-sheet/v-sheet.test.ts b/src/components/v-sheet/v-sheet.test.ts new file mode 100644 index 0000000000..14d1be6d06 --- /dev/null +++ b/src/components/v-sheet/v-sheet.test.ts @@ -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; + + 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)'); + }); +}); diff --git a/src/components/v-sheet/v-sheet.vue b/src/components/v-sheet/v-sheet.vue new file mode 100644 index 0000000000..924f568050 --- /dev/null +++ b/src/components/v-sheet/v-sheet.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/components/v-slider/index.ts b/src/components/v-slider/index.ts new file mode 100644 index 0000000000..2aba9554cd --- /dev/null +++ b/src/components/v-slider/index.ts @@ -0,0 +1,4 @@ +import VSlider from './v-slider.vue'; + +export { VSlider }; +export default VSlider; diff --git a/src/components/v-slider/v-slider.readme.md b/src/components/v-slider/v-slider.readme.md new file mode 100644 index 0000000000..674fccc215 --- /dev/null +++ b/src/components/v-slider/v-slider.readme.md @@ -0,0 +1,62 @@ +# Slider + +```html + +``` + +## 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 + +``` + +## 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 + +``` + +## 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 + + + + +``` + +## 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 }` | diff --git a/src/components/v-slider/v-slider.story.ts b/src/components/v-slider/v-slider.story.ts new file mode 100644 index 0000000000..14358cb9c8 --- /dev/null +++ b/src/components/v-slider/v-slider.story.ts @@ -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: ` +
+ +
+value: {{value}}
+
+
+` +}); + +export const withTicks = () => ({ + data() { + return { + value: 12 + }; + }, + methods: { + onInput: action('input'), + onChange: action('change'), + clickPrepend: action('click:prepend'), + clickAppend: action('click:append') + }, + template: ` + +` +}); + +export const withThumbLabel = () => ({ + data() { + return { + value: 12 + }; + }, + methods: { + onInput: action('input'), + onChange: action('change'), + clickPrepend: action('click:prepend'), + clickAppend: action('click:append') + }, + template: ` + +` +}); + +export const appendSlot = () => ({ + data() { + return { + value: 12 + }; + }, + methods: { + onInput: action('input'), + onChange: action('change'), + clickPrepend: action('click:prepend'), + clickAppend: action('click:append') + }, + template: ` + + + +` +}); + +export const prependSlot = () => ({ + data() { + return { + value: 12 + }; + }, + methods: { + onInput: action('input'), + onChange: action('change'), + clickPrepend: action('click:prepend'), + clickAppend: action('click:append') + }, + template: ` + + + +` +}); + +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: ` + + + + +` +}); + +export const thumbLabelSlot = () => ({ + data() { + return { + value: 12 + }; + }, + methods: { + onInput: action('input'), + onChange: action('change') + }, + template: ` + + + +` +}); diff --git a/src/components/v-slider/v-slider.test.ts b/src/components/v-slider/v-slider.test.ts new file mode 100644 index 0000000000..f4643ec56a --- /dev/null +++ b/src/components/v-slider/v-slider.test.ts @@ -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; + + 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: '
prepend
', + append: '
append
' + } + }); + + expect(component.find('.prepend > div').html()).toBe('
prepend
'); + expect(component.find('.append > div').html()).toBe('
append
'); + }); +}); diff --git a/src/components/v-slider/v-slider.vue b/src/components/v-slider/v-slider.vue new file mode 100644 index 0000000000..180cd67c8e --- /dev/null +++ b/src/components/v-slider/v-slider.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/components/v-spinner/index.ts b/src/components/v-spinner/index.ts new file mode 100644 index 0000000000..d87fa3d1e9 --- /dev/null +++ b/src/components/v-spinner/index.ts @@ -0,0 +1,4 @@ +import VSpinner from './v-spinner.vue'; + +export { VSpinner }; +export default VSpinner; diff --git a/src/components/v-spinner/v-spinner.readme.md b/src/components/v-spinner/v-spinner.readme.md new file mode 100644 index 0000000000..42f6bfeef4 --- /dev/null +++ b/src/components/v-spinner/v-spinner.readme.md @@ -0,0 +1,62 @@ +# Spinner + +```html + +``` + +## 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 + + + + + +``` + +The background color can be set in similar fashion: + +```html + + + + + +``` + + +## 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 + + + + + + +``` + +## 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` | diff --git a/src/components/v-spinner/v-spinner.story.ts b/src/components/v-spinner/v-spinner.story.ts new file mode 100644 index 0000000000..4a04e49588 --- /dev/null +++ b/src/components/v-spinner/v-spinner.story.ts @@ -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: ` + ` +}); + +export const colors = () => ` +
+ + + + + +
+`; + +export const sizes = () => ` +
+ + + + + +
+`; + +export const speed = () => ` +
+ + + + + +
+`; diff --git a/src/components/v-spinner/v-spinner.test.ts b/src/components/v-spinner/v-spinner.test.ts new file mode 100644 index 0000000000..94ee9d7d9b --- /dev/null +++ b/src/components/v-spinner/v-spinner.test.ts @@ -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; + + 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')); + }); + }); +}); diff --git a/src/components/v-spinner/v-spinner.vue b/src/components/v-spinner/v-spinner.vue new file mode 100644 index 0000000000..1a90ae75fa --- /dev/null +++ b/src/components/v-spinner/v-spinner.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/components/v-switch/index.ts b/src/components/v-switch/index.ts new file mode 100644 index 0000000000..69b3a8107d --- /dev/null +++ b/src/components/v-switch/index.ts @@ -0,0 +1,4 @@ +import VSwitch from './v-switch.vue'; + +export { VSwitch }; +export default VSwitch; diff --git a/src/components/v-switch/v-switch.readme.md b/src/components/v-switch/v-switch.readme.md new file mode 100644 index 0000000000..50f1ea5dcc --- /dev/null +++ b/src/components/v-switch/v-switch.readme.md @@ -0,0 +1,67 @@ +# Switch + +## Basic usage + +```html + +``` + +## Colors + +The switch component accepts any CSS color value, or variable name: + +```html + + + + +``` + +## Boolean vs arrays + +Just as with regular checkboxes, you can use `v-model` with both an array and a boolean: + + +```html + + + +``` + +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. | diff --git a/src/components/v-switch/v-switch.story.ts b/src/components/v-switch/v-switch.story.ts new file mode 100644 index 0000000000..bfb49507cb --- /dev/null +++ b/src/components/v-switch/v-switch.story.ts @@ -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: ` +
+ +
{{checked}}
+
+ ` +}); + +export const arrayState = () => ({ + methods: { + onChange: action('change') + }, + data() { + return { + options: ['html', 'css'] + }; + }, + template: ` +
+ + + +
{{options}}
+
+ ` +}); + +export const disabled = () => + `
`; + +export const colors = () => ({ + methods: { + onChange: action('change') + }, + data() { + return { + options: ['red', 'yellow', 'custom'] + }; + }, + props: { + customColor: { + default: color('Custom color', '#4CAF50') + } + }, + template: ` +
+ + + + +
+ ` +}); + +export const htmlLabel = () => ({ + methods: { + onChange: action('change') + }, + data() { + return { + checked: true + }; + }, + template: ` + + + + ` +}); diff --git a/src/components/v-switch/v-switch.test.ts b/src/components/v-switch/v-switch.test.ts new file mode 100644 index 0000000000..8f8f51bb85 --- /dev/null +++ b/src/components/v-switch/v-switch.test.ts @@ -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']); + }); +}); diff --git a/src/components/v-switch/v-switch.vue b/src/components/v-switch/v-switch.vue new file mode 100644 index 0000000000..905a5595ed --- /dev/null +++ b/src/components/v-switch/v-switch.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/v-table/_table-header.test.ts b/src/components/v-table/_table-header.test.ts new file mode 100644 index 0000000000..7a4b3dfc36 --- /dev/null +++ b/src/components/v-table/_table-header.test.ts @@ -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; + + 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': '' + } + }); + + expect(component.find('.v-table_table-header th:nth-child(2) .content > *').html()).toEqual( + '

Column 2

' + ); + }); + + 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' }); + }); +}); diff --git a/src/components/v-table/_table-header.vue b/src/components/v-table/_table-header.vue new file mode 100644 index 0000000000..d63975f61f --- /dev/null +++ b/src/components/v-table/_table-header.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/src/components/v-table/_table-row.test.ts b/src/components/v-table/_table-row.test.ts new file mode 100644 index 0000000000..74bf504d9c --- /dev/null +++ b/src/components/v-table/_table-row.test.ts @@ -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; + + 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': '' + } + }); + + expect(component.find('.v-table_table-row td:nth-child(2) > *').html()).toEqual( + '

Test 1 Col 2

' + ); + }); + + 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 + } + ]); + }); +}); diff --git a/src/components/v-table/_table-row.vue b/src/components/v-table/_table-row.vue new file mode 100644 index 0000000000..a768b914e9 --- /dev/null +++ b/src/components/v-table/_table-row.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/components/v-table/index.ts b/src/components/v-table/index.ts new file mode 100644 index 0000000000..eb694b975a --- /dev/null +++ b/src/components/v-table/index.ts @@ -0,0 +1,4 @@ +import VTable from './v-table.vue'; + +export { VTable }; +export default VTable; diff --git a/src/components/v-table/types.ts b/src/components/v-table/types.ts new file mode 100644 index 0000000000..ade5c61b75 --- /dev/null +++ b/src/components/v-table/types.ts @@ -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; + +export type ItemSelectEvent = { + value: boolean; + item: any; +}; + +export type Sort = { + by: string | null; + desc: boolean; +}; diff --git a/src/components/v-table/v-table.readme.md b/src/components/v-table/v-table.readme.md new file mode 100644 index 0000000000..3568c76cf5 --- /dev/null +++ b/src/components/v-table/v-table.readme.md @@ -0,0 +1,141 @@ +# Table + +```html + +``` + +## 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 + + + +``` + +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 + + + +``` + +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 + + + +``` + +## 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 | diff --git a/src/components/v-table/v-table.story.ts b/src/components/v-table/v-table.story.ts new file mode 100644 index 0000000000..d22bc53467 --- /dev/null +++ b/src/components/v-table/v-table.story.ts @@ -0,0 +1,1054 @@ +import { withKnobs, number } from '@storybook/addon-knobs'; +import { Sort } from './types'; + +import Vue from 'vue'; +import VTable from './v-table.vue'; +import markdown from './v-table.readme.md'; +import { action } from '@storybook/addon-actions'; + +Vue.component('v-table', VTable); + +export default { + title: 'Components / Table', + component: VTable, + decorators: [withKnobs], + parameters: { + notes: markdown + } +}; + +export const simple = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel' + } + ], + items: [ + { + name: 'Amsterdam', + tel: '(020) 333-0987' + }, + { + name: 'Beverly Hills', + tel: '(123) 333-0987' + }, + { + name: 'New Haven', + tel: '(203) 687-9900' + }, + { + name: 'Hong Kong', + tel: '(430) 709-4011' + }, + { + name: 'Ahmedabad', + tel: '(330) 777-3240' + } + ] + }; + }, + template: ` + + ` +}); + +export const alignment = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name', + align: 'left' + }, + { + text: 'Phone', + value: 'tel', + align: 'center' + }, + { + text: 'Contact', + value: 'contact', + align: 'right' + } + ], + items: [ + { + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + template: ` + + ` +}); + +export const customRow = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel' + }, + { + text: 'Contact', + value: 'contact' + } + ], + items: [ + { + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + template: ` + + + + ` +}); + +export const customHeader = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel' + }, + { + text: 'Contact', + value: 'contact' + } + ], + items: [ + { + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + template: ` + + + + ` +}); + +export const sorting = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel', + sortable: false + }, + { + text: 'Contact', + value: 'contact' + } + ], + items: [ + { + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ], + sort: { + by: 'name', + desc: false + } + }; + }, + template: ` +
+ +

Table syncs the sort prop when using \`sync\` modifier:

+
+sort: {{ sort }}
+
+

Defaults to first sortable column that's passed in (\`name\` in this example)

+
+ ` +}); + +export const selectable = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel', + sortable: false + }, + { + text: 'Contact', + value: 'contact' + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ], + selection: [] + }; + }, + template: ` +
+ +
+selection: {{ selection }}
+		
+
+ ` +}); + +export const fixedHeader = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel', + sortable: false + }, + { + text: 'Contact', + value: 'contact' + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + }, + { + id: 6, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 7, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 8, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 9, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 10, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + }, + { + id: 11, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 12, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 13, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 14, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 15, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + }, + { + id: 16, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 17, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 18, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 19, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 20, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + template: ` + + ` +}); + +export const loading = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel', + sortable: false + }, + { + text: 'Contact', + value: 'contact' + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + template: ` +
+ +
+ ` +}); + +export const loadingNoRows = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel', + sortable: false + }, + { + text: 'Contact', + value: 'contact' + } + ], + items: [] + }; + }, + template: ` +
+ +
+ ` +}); + +loadingNoRows.title = 'Loading (No Rows)'; + +export const columnWidths = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name', + width: 150 + }, + { + text: 'Phone', + value: 'tel', + width: 150 + }, + { + text: 'Contact', + value: 'contact', + width: 250 + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + template: ` +
+ +
+ ` +}); + +export const resizable = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name' + }, + { + text: 'Phone', + value: 'tel', + width: 150 + }, + { + text: 'Contact', + value: 'contact', + width: 250 + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + template: ` +
+ +
+headers: {{ headers }}
+
+
+ ` +}); + +export const serverSort = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name', + width: 150 + }, + { + text: 'Phone', + value: 'tel', + width: 150 + }, + { + text: 'Contact', + value: 'contact', + width: 250 + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ], + sort: { + by: 'id', + desc: false + }, + loading: false + }; + }, + methods: { + onSort(sort: Sort) { + const self: any = this; + self.loading = true; + + setTimeout(() => { + self.items = [...self.items].sort((a, b) => (a[sort.by!] > b[sort.by!] ? 1 : -1)); + + if (sort.desc === true) { + self.items.reverse(); + } + + self.sort = sort; + self.loading = false; + }, 2000); + } + }, + template: ` + + ` +}); + +export const dragNDrop = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name', + width: 150 + }, + { + text: 'Phone', + value: 'tel', + width: 150 + }, + { + text: 'Contact', + value: 'contact', + width: 250 + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ], + sort: { + by: '$manual', + desc: false + } + }; + }, + methods: { + onDrop: action('drop') + }, + template: ` +
+ +
+items: {{ items }}
+		
+
+ ` +}); + +export const rowClick = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name', + width: 150 + }, + { + text: 'Phone', + value: 'tel', + width: 150 + }, + { + text: 'Contact', + value: 'contact', + width: 250 + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + methods: { + onClick: action('click:row') + }, + template: ` +
+ +
+ ` +}); + +export const rowHeight = () => ({ + data() { + return { + headers: [ + { + text: 'Name', + value: 'name', + width: 150 + }, + { + text: 'Phone', + value: 'tel', + width: 150 + }, + { + text: 'Contact', + value: 'contact', + width: 250 + } + ], + items: [ + { + id: 1, + name: 'Amsterdam', + tel: '(020) 333-0987', + contact: 'Mariann Rumble' + }, + { + id: 2, + name: 'Beverly Hills', + tel: '(123) 333-0987', + contact: 'Kathy Baughan' + }, + { + id: 3, + name: 'New Haven', + tel: '(203) 687-9900', + contact: 'Fleur Tebbet' + }, + { + id: 4, + name: 'Hong Kong', + tel: '(430) 709-4011', + contact: 'Rodolph Tofful' + }, + { + id: 5, + name: 'Ahmedabad', + tel: '(330) 777-3240', + contact: 'Helenka Killely' + } + ] + }; + }, + props: { + rowHeight: { + default: number('Row height', 48) + } + }, + template: ` +
+ +
+ ` +}); diff --git a/src/components/v-table/v-table.test.ts b/src/components/v-table/v-table.test.ts new file mode 100644 index 0000000000..1ec7b4f1eb --- /dev/null +++ b/src/components/v-table/v-table.test.ts @@ -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; + + 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 }]); + }); +}); diff --git a/src/components/v-table/v-table.vue b/src/components/v-table/v-table.vue new file mode 100644 index 0000000000..e60b97482b --- /dev/null +++ b/src/components/v-table/v-table.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/src/compositions/event-listener.test.ts b/src/compositions/event-listener.test.ts new file mode 100644 index 0000000000..4b05971d6b --- /dev/null +++ b/src/compositions/event-listener.test.ts @@ -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); + }); +}); diff --git a/src/compositions/event-listener.ts b/src/compositions/event-listener.ts new file mode 100644 index 0000000000..b363808442 --- /dev/null +++ b/src/compositions/event-listener.ts @@ -0,0 +1,18 @@ +import { onMounted, onBeforeUnmount, Ref, isRef } from '@vue/composition-api'; + +export default function useEventListener( + target: T | Ref, + 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); + }); +} diff --git a/src/compositions/window-size.test.ts b/src/compositions/window-size.test.ts new file mode 100644 index 0000000000..c1ed2a3ff8 --- /dev/null +++ b/src/compositions/window-size.test.ts @@ -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); + }); +}); diff --git a/src/compositions/window-size.ts b/src/compositions/window-size.ts new file mode 100644 index 0000000000..a4a5c822f2 --- /dev/null +++ b/src/compositions/window-size.ts @@ -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 }; +} diff --git a/src/utils/parse-css-var.test.ts b/src/utils/parse-css-var.test.ts new file mode 100644 index 0000000000..0b3608efae --- /dev/null +++ b/src/utils/parse-css-var.test.ts @@ -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'); + }); +}); diff --git a/src/utils/parse-css-var.ts b/src/utils/parse-css-var.ts new file mode 100644 index 0000000000..1c79888af7 --- /dev/null +++ b/src/utils/parse-css-var.ts @@ -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; +}