Move styleguide to local folder (WIP)

This commit is contained in:
Maximilian Harz 2025-06-24 00:18:43 +02:00
parent 4e9b10d044
commit 017e840bbb
976 changed files with 15432 additions and 77 deletions

View File

@ -50,7 +50,7 @@
// import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
// import Logo from '~/components/Logo/Logo'
import { Container as DsContainer, Card as BaseCard, Space as DsSpace, Flex as DsFlex, FlexItem as DsFlexItem, Heading as DsHeading, Text as DsText } from 'ocelot-styleguide'
import { Container as DsContainer, Card as BaseCard, Space as DsSpace, Flex as DsFlex, FlexItem as DsFlexItem, Heading as DsHeading, Text as DsText } from '../lib/styleguide'
export default {
components: {

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,8 @@
<svg viewBox="0 0 239 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.5 14.449L43.5774 38.8316H15.4227L29.5 14.449Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.5 55C43.5833 55 55 43.5833 55 29.5C55 15.4167 43.5833 4 29.5 4C15.4167 4 4 15.4167 4 29.5C4 43.5833 15.4167 55 29.5 55ZM29.5 59C45.7924 59 59 45.7924 59 29.5C59 13.2076 45.7924 0 29.5 0C13.2076 0 0 13.2076 0 29.5C0 45.7924 13.2076 59 29.5 59Z" fill="currentColor"/>
<path d="M118.83 44.291C116.877 45.9707 114.689 47.2598 112.268 48.1582C109.846 49.0566 107.336 49.5059 104.738 49.5059C102.746 49.5059 100.822 49.2422 98.9668 48.7148C97.1309 48.207 95.4023 47.4844 93.7812 46.5469C92.1797 45.5898 90.7148 44.4473 89.3867 43.1191C88.0586 41.791 86.916 40.3262 85.959 38.7246C85.0215 37.1035 84.2891 35.375 83.7617 33.5391C83.2539 31.6836 83 29.7598 83 27.7676C83 25.7754 83.2539 23.8516 83.7617 21.9961C84.2891 20.1406 85.0215 18.4121 85.959 16.8105C86.916 15.1895 88.0586 13.7148 89.3867 12.3867C90.7148 11.0586 92.1797 9.92578 93.7812 8.98828C95.4023 8.03125 97.1309 7.29883 98.9668 6.79102C100.822 6.26367 102.746 6 104.738 6C107.336 6 109.846 6.44922 112.268 7.34766C114.689 8.22656 116.877 9.51562 118.83 11.2148L114.377 18.5391C113.146 17.2109 111.691 16.1953 110.012 15.4922C108.332 14.7695 106.574 14.4082 104.738 14.4082C102.883 14.4082 101.145 14.7598 99.5234 15.4629C97.9023 16.166 96.4863 17.123 95.2754 18.334C94.0645 19.5254 93.1074 20.9414 92.4043 22.582C91.7012 24.2031 91.3496 25.9316 91.3496 27.7676C91.3496 29.6035 91.7012 31.332 92.4043 32.9531C93.1074 34.5547 94.0645 35.9609 95.2754 37.1719C96.4863 38.3828 97.9023 39.3398 99.5234 40.043C101.145 40.7461 102.883 41.0977 104.738 41.0977C106.574 41.0977 108.332 40.7461 110.012 40.043C111.691 39.3203 113.146 38.2949 114.377 36.9668L118.83 44.291Z" fill="currentColor"/>
<path d="M138.131 48.5977H129.723V6.58594H138.131V48.5977Z" fill="currentColor"/>
<path d="M193.115 27.7676C193.115 29.7598 192.852 31.6836 192.324 33.5391C191.816 35.375 191.094 37.1035 190.156 38.7246C189.219 40.3262 188.086 41.791 186.758 43.1191C185.43 44.4473 183.965 45.5898 182.363 46.5469C180.762 47.4844 179.033 48.207 177.178 48.7148C175.322 49.2422 173.398 49.5059 171.406 49.5059C169.414 49.5059 167.49 49.2422 165.635 48.7148C163.799 48.207 162.07 47.4844 160.449 46.5469C158.848 45.5898 157.383 44.4473 156.055 43.1191C154.727 41.791 153.584 40.3262 152.627 38.7246C151.689 37.1035 150.957 35.375 150.43 33.5391C149.922 31.6836 149.668 29.7598 149.668 27.7676C149.668 25.7754 149.922 23.8516 150.43 21.9961C150.957 20.1406 151.689 18.4121 152.627 16.8105C153.584 15.209 154.727 13.7441 156.055 12.416C157.383 11.0879 158.848 9.95508 160.449 9.01758C162.07 8.08008 163.799 7.35742 165.635 6.84961C167.49 6.32227 169.414 6.05859 171.406 6.05859C173.398 6.05859 175.322 6.32227 177.178 6.84961C179.033 7.35742 180.762 8.08008 182.363 9.01758C183.965 9.95508 185.43 11.0879 186.758 12.416C188.086 13.7441 189.219 15.209 190.156 16.8105C191.094 18.4121 191.816 20.1406 192.324 21.9961C192.852 23.8516 193.115 25.7754 193.115 27.7676ZM184.766 27.7676C184.766 25.9316 184.414 24.2031 183.711 22.582C183.008 20.9414 182.051 19.5254 180.84 18.334C179.648 17.123 178.232 16.166 176.592 15.4629C174.971 14.7598 173.242 14.4082 171.406 14.4082C169.551 14.4082 167.812 14.7598 166.191 15.4629C164.57 16.166 163.154 17.123 161.943 18.334C160.732 19.5254 159.775 20.9414 159.072 22.582C158.369 24.2031 158.018 25.9316 158.018 27.7676C158.018 29.6035 158.369 31.332 159.072 32.9531C159.775 34.5547 160.732 35.9609 161.943 37.1719C163.154 38.3828 164.57 39.3398 166.191 40.043C167.812 40.7461 169.551 41.0977 171.406 41.0977C173.242 41.0977 174.971 40.7461 176.592 40.043C178.232 39.3398 179.648 38.3828 180.84 37.1719C182.051 35.9609 183.008 34.5547 183.711 32.9531C184.414 31.332 184.766 29.6035 184.766 27.7676Z" fill="currentColor"/>
<path d="M238.256 48.5977H229.262L213.061 20.9414V48.5977H204.652V6.58594H213.646L229.848 34.2715V6.58594H238.256V48.5977Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,128 @@
<template>
<div
class="ds-avatar"
:class="[
`ds-size-${this.size}`,
online && 'is-online'
]"
:style="styles"
>
<ds-flex
v-if="!hasImage || error"
style="height: 100%">
<ds-flex-item centered>
<template v-if="isAnonymus">
<ds-icon name="eye-slash" />
</template>
<template v-else>
{{ userInitials }}
</template>
</ds-flex-item>
</ds-flex>
<img
v-if="image && !error"
:src="image"
@error="onError"
>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import helpers from './lib/helpers.js'
import { tokens } from '@@/styles/tokens'
import { camelCase } from 'change-case'
const upperFirst = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
export default defineComponent({
name: 'DsAvatar',
props: {
backgroundColor: { type: String, default: null },
name: { type: String, default: 'Anonymus' },
/**
* The size used for the avatar.
* @options small|base|large
*/
size: {
type: String,
default: 'base',
validator: value => {
return value.match(/(small|base|large|x-large)/)
}
},
image: { type: String, default: null },
online: { type: Boolean, default: false }
},
data() {
return {
error: false
}
},
computed: {
isAnonymus() {
return !this.name || this.name.toLowerCase() === 'anonymus'
},
styles() {
let size = this.sizeValue
if (Number.isInteger(Number(size))) {
size = `${size}px`
}
const nummericSize = Number.parseInt(size)
return {
width: size,
height: size,
backgroundColor: this.hasImage ? 'white' : this.background,
fontSize: Math.floor(nummericSize / 2.5) + 'px',
fontWeight: 'bold',
color: this.fontColor
}
},
sizeValue() {
return tokens[`sizeAvatar${upperFirst(camelCase(this.size))}`]
},
hasImage() {
return Boolean(this.image) && !this.error
},
userInitials() {
return helpers.initials(this.name)
},
background() {
return (
this.backgroundColor ||
helpers.randomBackgroundColor(
this.name.length,
helpers.backgroundColors
)
)
},
fontColor() {
return this.color || helpers.lightenColor(this.background, 200)
}
},
methods: {
onError() {
this.error = true
},
updateSize() {
if (this.hasImage) {
return
}
try {
this.size = this.$refs.avatar.getBoundingClientRect().width
} catch (err) {
// nothing
}
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,65 @@
export default {
backgroundColors: [
'#295E87',
'#007D93',
'#933D86',
'#005093',
'#4A5580',
'#0067A5',
'#007D93',
'#007A70',
'#008F6D',
'#008255',
'#43913A',
'#C61A6A',
'#15A748',
'#007FBA',
'#008AC4',
'#E1B424',
'#00753C',
'#B42554',
'#4F3B68',
'#EB8B2D',
'#DC3E2A',
'#A7BE33',
'#DF542A',
'#00A3DA',
'#84BF41'
],
initials(name) {
let un = name || 'Anonymus'
let parts = un.split(/[ -]/)
let initials = ''
for (var i = 0; i < parts.length; i++) {
initials += parts[i].charAt(0)
}
if (initials.length > 3 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '')
}
initials = initials.substr(0, 3).toUpperCase()
return initials
},
randomBackgroundColor(seed, colors) {
return colors[seed % colors.length]
},
lightenColor(hex, amt) {
// From https://css-tricks.com/snippets/javascript/lighten-darken-color/
var usePound = false
if (hex[0] === '#') {
hex = hex.slice(1)
usePound = true
}
var num = parseInt(hex, 16)
var r = (num >> 16) + amt
if (r > 255) r = 255
else if (r < 0) r = 0
var b = ((num >> 8) & 0x00ff) + amt
if (b > 255) b = 255
else if (b < 0) b = 0
var g = (num & 0x0000ff) + amt
if (g > 255) g = 255
else if (g < 0) g = 0
return (usePound ? '#' : '') + (g | (b << 8) | (r << 16)).toString(16)
}
}

View File

@ -0,0 +1,79 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-avatar {
@include reset;
border-radius: 50%;
display: inline-block;
position: relative;
margin-right: $space-xx-small;
min-height: 22px;
min-width: 22px;
text-align: center;
&::after {
content: "";
position: absolute;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
box-shadow: inset 0 0 0 1px rgba($color-neutral-0, .1);
border-radius: 50%;
}
&.is-online::before {
content: "";
position: absolute;
width: 33%;
height: 33%;
bottom: 0;
right: 0;
border: 2px solid $background-color-base;
border-radius: 100%;
background-color: $color-success;
z-index: 1;
}
img {
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
object-fit: cover;
object-position: center;
}
.ds-icon {
margin-top: -0.1em;
}
&.ds-size-small {
width: $size-avatar-small;
height: $size-avatar-small;
&.is-online::before {
border-width: 1px;
}
}
&.ds-size-base {
border-width: 1px;
width: $size-avatar-base;
height: $size-avatar-base;
}
&.ds-size-large {
width: $size-avatar-large;
height: $size-avatar-large;
}
&.ds-size-x-large {
width: $size-avatar-x-large;
height: $size-avatar-x-large;
&.is-online::before {
height: $space-large;
width: $space-large;
border-width: 3px;
}
}
}

View File

@ -0,0 +1,89 @@
<template>
<component
:is="tag"
class="ds-copy-field"
:class="`ds-copy-field-${size}`">
<div ref="text">
<slot />
</div>
<div
class="ds-copy-field-link">
<ds-button
@click="copy"
icon="copy"
color="soft"
ghost/>
</div>
<transition name="ds-copy-field-message">
<div
v-show="showMessage"
class="ds-copy-field-message">
<div
class="ds-copy-field-message-text"
ref="messageText"/>
</div>
</transition>
</component>
</template>
<script lang="ts">
import { defineComponent, nextTick } from 'vue';
import copyToClipboard from 'clipboard-copy'
import DsButton from '@@/components/navigation/Button/Button.vue';
/**
* A copy field is used to present text that can easily
* be copied to the users clipboard by clicking on it.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsCopyField',
components: {
DsButton
},
props: {
/**
* The size used for the text.
* @options small|base|large
*/
size: {
type: String,
default: 'base',
validator: value => {
return value.match(/(small|base|large)/)
}
},
/**
* The html element name used for the copy field.
*/
tag: {
type: String,
default: 'div'
}
},
data() {
return {
showMessage: false
}
},
methods: {
copy() {
const content = this.$refs.text.innerText
this.$refs.messageText.innerText = content
copyToClipboard(content)
this.showMessage = true
nextTick(() => {
this.showMessage = false
})
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CopyField.vue matches snapshot 1`] = `
<div
class="ds-copy-field ds-copy-field-base"
>
<div>
Test
</div>
<div
class="ds-copy-field-link"
>
<dsbutton-stub
color="soft"
ghost="true"
icon="copy"
linktag="button"
/>
</div>
<div
class="ds-copy-field-message"
name="ds-copy-field-message"
style="display: none;"
>
<div
class="ds-copy-field-message-text"
/>
</div>
</div>
`;

View File

@ -0,0 +1,27 @@
import { describe, expect, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import Comp from './CopyField.vue'
describe('CopyField.vue', () => {
let wrapper
beforeEach(() => {
wrapper = shallowMount(Comp, {
slots: {
default: 'Test'
}
})
})
it('defaults to div', () => {
expect(wrapper.props().tag).toEqual('div')
})
it('displays text', () => {
expect(wrapper.text()).toEqual('Test')
})
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot()
})
})

View File

@ -0,0 +1,63 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens";
.ds-copy-field {
@include reset;
@include stack-space(tokens.$space-xx-small);
position: relative;
font-family: tokens.$font-family-text;
line-height: tokens.$line-height-base;
padding: tokens.$space-x-small tokens.$space-small;
border-radius: tokens.$border-radius-base;
letter-spacing: tokens.$letter-spacing-small;
background-color: tokens.$background-color-softer;
}
.ds-copy-field-small {
font-size: tokens.$font-size-small;
}
.ds-copy-field-large {
font-size: tokens.$font-size-large;
}
.ds-copy-field-link {
@include reset;
position: absolute;
right: tokens.$space-xx-small;
top: 50%;
transform: translateY(-50%);
user-select: none;
}
.ds-copy-field-message {
@include reset;
position: absolute;
overflow: hidden;
left: 0;
right: 0;
top: 0;
bottom: 0;
user-select: none;
visibility: visible;
opacity: 1;
transition: all tokens.$duration-x-long tokens.$ease-out;
}
.ds-copy-field-message-text {
@include reset;
padding: tokens.$space-x-small tokens.$space-small;
transition: all tokens.$duration-x-long tokens.$ease-out;
transform: scale(1);
transform-origin: 0 50%;
}
.ds-copy-field-message-enter,
.ds-copy-field-message-leave-to {
visibility: hidden;
opacity: 0;
.ds-copy-field-message-text {
transform: scale(1.2);
}
}

View File

@ -0,0 +1,65 @@
<template>
<component
:is="ordered ? 'ol' : 'ul'"
class="ds-list"
:class="[
size && `ds-list-size-${size}`
]">
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Used in combination with the list item component to display lists of data.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsList',
provide() {
return {
$parentList: this
}
},
inject: {
$parentList: {
default: null
}
},
props: {
/**
* Whether or not the list is ordered.
*/
ordered: {
type: Boolean,
default: false
},
/**
* The size used for the list.
* @options small|base|large|x-large
*/
size: {
type: String,
default: null,
validator: value => {
return value.match(/(small|base|large|x-large)/)
}
},
/**
* The name of the list icon.
*/
icon: {
type: String,
default: 'angle-right'
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,44 @@
<template>
<li class="ds-list-item">
<span class="ds-list-item-prefix">
<span
v-if="!$parentList.ordered"
class="ds-list-item-icon">
<ds-icon :name="icon"/>
</span>
</span>
<span class="ds-list-item-content">
<slot />
</span>
</li>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* @version 1.0.0
* @see DsList
*/
export default defineComponent({
name: 'DsListItem',
inject: {
$parentList: {
default: null
}
},
props: {
/**
* The name of the list icon.
*/
icon: {
type: String,
default() {
return this.$parentList ? this.$parentList.icon : 'angle-right'
}
}
},
});
</script>

View File

@ -0,0 +1,63 @@
@use "@@/styles/shared.scss" as *;
@use "@@/styles/tokens/tokens.scss" as *;
.ds-list {
@include reset;
@include stack-space($font-space-x-large);
font-family: $font-family-text;
line-height: $line-height-base;
list-style-type: none;
text-align: left;
}
ol.ds-list {
counter-reset: list-counter;
}
.ds-list-size-small {
font-size: $font-size-small;
}
.ds-list-size-base {
font-size: $font-size-base;
}
.ds-list-size-large {
font-size: $font-size-large;
}
.ds-list-size-x-large {
font-size: $font-size-x-large;
}
.ds-list-item {
@include reset;
@include stack-space($font-space-x-large);
display: flex;
}
.ds-list-item-prefix {
flex: 0 0 $font-space-xxx-large;
color: $text-color-primary;
}
.ds-list-item-content {
@include layout-flex-fix;
flex: 1 1 0;
}
ol > .ds-list-item > .ds-list-item-prefix:before, .ds-list-item-icon {
display: flex;
transform: translateY(0.2em);
align-items: center;
justify-content: center;
width: $font-space-xxx-large;
height: $font-space-xxx-large + .4em;
font-size: 0.6em;
color: $text-color-soft;
}
ol > .ds-list-item > .ds-list-item-prefix:before {
content: counter(list-counter);
counter-increment: list-counter;
}

View File

@ -0,0 +1,42 @@
<template>
<div
class="ds-number"
:class="[
size && `ds-number-size-${size}`
]"
>
<ds-text
:size="size"
class="ds-number-count"
style="margin-bottom: 0">
<slot name="count">{{ count }}</slot>
</ds-text>
<ds-text
:uppercase="uppercase"
:size="labelSize"
class="ds-number-label"
color="soft">
{{ label }}
</ds-text>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'DsNumber',
props: {
size: { type: String, default: 'x-large' },
labelSize: { type: String, default: 'small' },
count: { type: [Number, String], default: 0 },
label: { type: String, default: null },
uppercase: { type: Boolean, default: false }
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,17 @@
@use "@@/styles/tokens/tokens.scss" as *;
.ds-number {
text-align: center;
}
.ds-number-count {
font-weight: bold;
}
.ds-number-label {
font-size: $font-size-small;
}
// .ds-number-size-x-large,
// .ds-number-size-xx-large {
// .ds-number-label {
// font-size: $font-size-base;
// }
// }

View File

@ -0,0 +1,180 @@
<template>
<div
class="ds-table-wrap"
v-if="dataArray">
<table
cellpadding="0"
cellspacing="0"
class="ds-table"
:class="[
condensed && 'ds-table-condensed',
bordered && 'ds-table-bordered'
]">
<colgroup>
<col
v-for="header in headers"
:key="header.key"
:width="header.width">
</colgroup>
<thead>
<tr>
<ds-table-head-col
v-for="header in headers"
:key="header.key"
:align="align(header.key)">
{{ header.label }}
</ds-table-head-col>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="row.key || index">
<ds-table-col
v-for="col in row"
:key="col.key"
:align="align(col.key)">
<!-- @slot Slots are named by fields -->
<slot
:name="col.key"
:row="dataArray[index] ? dataArray[index] : null"
:col="col"
:index="index">
{{ col.value }}
</slot>
</ds-table-col>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
function startCase(string) {
const capitalize = ([first='', ...rest]) => first.toUpperCase() + rest.join('');
return string.split(' ').map(capitalize).join(' ');
}
/**
* Used in combination with the table row to create data tables.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsTable',
provide() {
return {
$parentTable: this
}
},
props: {
/**
* The table's data
*/
data: {
type: [Array, Object],
default() {
return []
}
},
/**
* The table's header config
*/
fields: {
type: [Array, Object],
default() {
return null
}
},
/**
* Should the table be more condense?
*/
condensed: {
type: Boolean,
default: false
},
/**
* Should the table have borders?
*/
bordered: {
type: Boolean,
default: true
}
},
computed: {
dataArray() {
if (Array.isArray(this.data)) {
return this.data
}
if (typeof this.data === 'object') {
return Object.keys(this.data).map(key => this.data[key])
}
return []
},
headers() {
let keys = this.dataArray[0] ? Object.keys(this.dataArray[0]) : []
let headerObj = {}
if (this.fields) {
if (Array.isArray(this.fields)) {
keys = this.fields
} else if (typeof this.fields === 'object') {
keys = Object.keys(this.fields)
headerObj = this.fields
}
}
return keys.map(key => {
let header = {
key,
label: this.parseLabel(key),
width: ''
}
if (headerObj[key]) {
const headerMerge =
typeof headerObj[key] === 'string'
? { label: headerObj[key] }
: headerObj[key]
header = Object.assign(header, headerMerge)
}
return header
})
},
rows() {
let keys = this.dataArray[0] ? Object.keys(this.dataArray[0]) : []
return this.dataArray.map(row => {
if (this.fields) {
keys = Array.isArray(this.fields)
? this.fields
: Object.keys(this.fields)
}
return keys.map(key => {
return {
key,
value: row[key]
}
})
})
}
},
methods: {
align(colKey) {
return this.fields && this.fields[colKey]
? this.fields[colKey].align
: null
},
parseLabel(label) {
return startCase(label)
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,53 @@
<template>
<!-- eslint-disable -->
<td
class="ds-table-col"
:class="[
align && `ds-table-col-${align}`
]">
<slot/>
</td>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Used in combination with the table component to create data tables.
* @version 1.0.0
* @see DsTable
* @private
*/
export default defineComponent({
name: 'DsTableCol',
inject: {
$parentTable: {
default: null
}
},
props: {
/**
* The column width
*/
width: {
type: [String, Number, Object],
default: null
},
/**
* The column align
* @options left|center|right
*/
align: {
type: String,
default: null,
validator: value => {
return value.match(/(left|center|right)/)
}
}
},
computed: {},
});
</script>

View File

@ -0,0 +1,63 @@
<template>
<th
class="ds-table-head-col"
:class="[
align && `ds-table-head-col-${align}`
]">
<slot>
{{ label }}
</slot>
</th>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Used in combination with the table component to create data tables.
* @version 1.0.0
* @see DsTable
* @private
*/
export default defineComponent({
name: 'DsTableHeadCol',
inject: {
$parentTable: {
default: null
}
},
props: {
/**
* The column value
*/
label: {
type: [Number, String, Array, Object],
default() {
return null
}
},
/**
* The column width
*/
width: {
type: [String, Number, Object],
default: null
},
/**
* The column align
* @options left|center|right
*/
align: {
type: String,
default: null,
validator: value => {
return value.match(/(left|center|right)/)
}
}
},
computed: {},
});
</script>

View File

@ -0,0 +1,68 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-table-wrap {
@include reset;
width: 100%;
overflow: auto;
}
.ds-table {
@include reset;
width: 100%;
}
.ds-table-col {
@include reset;
vertical-align: top;
padding: $space-small $space-xx-small;
&:last-child {
padding-right: 0;
}
}
.ds-table-head-col {
@include reset;
border-bottom: $border-color-softer solid $border-size-base;
padding: $space-small $space-xx-small;
text-align: left;
font-weight: $font-weight-bold;
}
// bordered
.ds-table-bordered {
.ds-table-col,
.ds-table-head-col {
border-bottom: $border-color-softer dotted $border-size-base;
}
tr:last-child .ds-table-col {
border-bottom: none;
}
}
// condensed
.ds-table-condensed {
.ds-table-col,
.ds-table-head-col {
padding-top: $space-x-small;
padding-bottom: $space-x-small;
}
}
.ds-table-col,
.ds-table-head-col {
&.ds-table-col-left,
&.ds-table-head-col-left {
text-align: left;
}
&.ds-table-col-center,
&.ds-table-head-col-center {
text-align: center;
}
&.ds-table-col-right,
&.ds-table-head-col-right {
text-align: right;
}
}

View File

@ -0,0 +1,159 @@
<template>
<form
class="ds-form"
@submit.prevent="submit"
novalidate="true"
autocomplete="off">
<slot
:errors="errors"
:reset="reset"/>
</form>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import Schema from 'async-validator'
import cloneDeep from 'clone-deep'
import { setProperty } from 'dot-prop'
// Disable warnings to console
Schema.warning = function() {}
/**
* Used for handling complex user input.
* @version 1.0.0
*/
export default defineComponent({
emits: ['submit', 'update:modelValue', 'input-valid', 'reset'],
name: 'DsForm',
provide() {
return {
$parentForm: this
}
},
props: {
/**
* The value of the input. Can be passed via v-model.
*/
modelValue: {
type: Object,
required: true
},
/**
* The async-validator schema used for the form data.
*/
schema: {
type: Object,
default: () => ({})
}
},
data() {
return {
newData: null,
subscriber: [],
errors: null
}
},
watch: {
modelValue: {
handler(value) {
this.newData = cloneDeep(value)
this.notify(value, this.errors)
},
deep: true
}
},
methods: {
submit() {
this.validate(() => {
/**
* Fires on form submit.
* Receives the current form data.
*
* @event submit
*/
this.$emit('submit', this.newData)
})
},
validate(cb) {
const validator = new Schema(this.schema)
validator.validate(this.newData, errors => {
if (errors) {
this.errors = errors.reduce((errorObj, error) => {
const result = { ...errorObj }
result[error.field] = error.message
return result
}, {})
} else {
this.errors = null
}
this.notify(this.newData, this.errors)
if (!errors && cb && typeof cb === 'function') {
cb()
}
})
},
subscribe(cb) {
if (cb && typeof cb === 'function') {
cb(cloneDeep(this.newData))
this.subscriber.push(cb)
}
},
unsubscribe(cb) {
const index = this.subscriber.findIndex(cb)
if (index > -1) {
this.subscriber.splice(index, 1)
}
},
notify(data, errors) {
this.subscriber.forEach(cb => {
cb(cloneDeep(data), errors)
})
},
async update(model, value) {
setProperty(this.newData, model, value)
/**
* Fires after user input.
* Receives the current form data.
* The form data is not validated and can be invalid.
* This event is fired before the input-valid event.
*
* @event input
*/
await this.$emit('update:modelValue', cloneDeep(this.newData))
this.validate(() => {
/**
* Fires after user input.
* Receives the current form data.
* This is only called if the form data is successfully validated.
*
* @event input-valid
*/
this.$emit('input-valid', cloneDeep(this.newData))
})
},
reset() {
/**
* Fires after reset() was called.
* Receives the current form data.
* Reset has to be handled manually.
*
* @event reset
*/
this.$emit('reset', cloneDeep(this.modelValue))
}
},
created() {
this.newData = cloneDeep(this.modelValue)
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,27 @@
<template>
<div
class="ds-form-item"
:class="$parentInput.stateClasses">
<ds-input-label
:label="$parentInput.label"
:for="$parentInput.id" />
<slot/>
<ds-input-error :error="$parentInput.error" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* @version 1.0.0
* @private
*/
export default defineComponent({
name: 'DsFormItem',
inject: ['$parentInput'],
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,29 @@
<template>
<transition name="ds-input-error">
<div
class="ds-input-error"
v-show="!!error">
{{ error }}
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* @version 1.0.0
* @private
*/
export default defineComponent({
name: 'DsInputError',
props: {
error: {
type: String,
required: false,
default: null
}
},
});
</script>

View File

@ -0,0 +1,27 @@
<template>
<label
class="ds-input-label"
v-show="!!label">
{{ label }}
</label>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* @version 1.0.0
* @private
*/
export default defineComponent({
name: 'DsInputLabel',
props: {
label: {
type: String,
required: false,
default: null
}
},
});
</script>

View File

@ -0,0 +1,32 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-form-item {
position: relative;
@include stack-space($space-small);
}
.ds-input-error {
color: $color-danger;
font-size: $font-size-x-small;
position: absolute;
top: 100%;
}
.ds-input-error-enter-active {
transition: opacity $duration-base $ease-out,
transform $duration-base $ease-out;
}
.ds-input-error-enter,
.ds-input-error-leave-to {
opacity: 0;
transform: translateY(-2px);
}
.ds-input-label {
padding-bottom: $space-xx-small;
color: $text-color-soft;
font-size: $font-size-base;
display: block;
}

View File

@ -0,0 +1,114 @@
<template>
<ds-form-item>
<div class="ds-input-wrap">
<div
v-if="icon"
class="ds-input-icon">
<ds-icon :name="icon"/>
</div>
<component
class="ds-input"
:class="[
icon && `ds-input-has-icon`,
iconRight && `ds-input-has-icon-right`
]"
:id="id"
:name="name ? name : model"
:type="type"
:autofocus="autofocus"
:placeholder="placeholder"
:tabindex="tabindex"
:disabled="disabled"
:readonly="readonly"
:is="tag"
:modelValue.prop="innerValue"
@update:modelValue="handleInput"
@focus="handleFocus"
@blur="handleBlur"
:rows="type === 'textarea' ? rows : null"
v-html="type === 'textarea' ? innerValue : null"/>
<div
v-if="iconRight"
class="ds-input-icon-right">
<ds-icon :name="iconRight"/>
</div>
</div>
</ds-form-item>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import inputMixin from '../shared/input'
/**
* Used for handling basic user input.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsInput',
mixins: [inputMixin],
props: {
/**
* The type of this input.
* @options url|text|password|email|search|textarea
*/
type: {
type: String,
default: 'text',
validator: value => {
return value.match(/(url|text|password|email|search|textarea)/)
}
},
/**
* The placeholder shown when value is empty.
*/
placeholder: {
type: String,
default: null
},
/**
* Whether the input should be automatically focused
*/
autofocus: {
type: Boolean,
default: false
},
/**
* How many rows this input should have (only for type="textarea")
*/
rows: {
type: [String, Number],
default: 1
},
/**
* The name of the input's icon.
*/
icon: {
type: String,
default: null
},
/**
* The name of the input's right icon.
*/
iconRight: {
type: String,
default: null
}
},
computed: {
tag() {
if (this.type === 'textarea') {
return 'textarea'
}
return 'input'
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,18 @@
@use '../shared/input.scss' as *;
@use "@@/styles/shared" as *;
@include input(ds-input);
textarea.ds-input {
height: auto;
min-height: $input-height;
resize: none;
}
textarea.ds-input-size-small {
min-height: $input-height-small;
}
textarea.ds-input-size-large {
min-height: $input-height-large;
}

View File

@ -0,0 +1,130 @@
<template>
<ds-form-item>
<div
class="ds-radio"
:tabindex="tabindex"
@keydown.self.down.prevent="pointerNext"
@keydown.self.up.prevent="pointerPrev">
<component
class="ds-radio-option"
:class="[
isSelected(option) && `ds-radio-option-is-selected`
]"
v-for="option in options"
@click="handleSelect(option)"
:key="option[labelProp] || option"
:is="buttons ? 'ds-button' : 'div'"
:primary="buttons && isSelected(option)">
<span
class="ds-radio-option-mark"
v-if="!buttons"/>
<span class="ds-radio-option-label">
<!-- @slot Slot to provide custom option items -->
<slot
name="option"
:option="option">
{{ option[labelProp] || option }}
</slot>
</span>
</component>
</div>
</ds-form-item>
</template>
<script lang="ts">
import { defineComponent, nextTick } from 'vue';
import inputMixin from '../shared/input'
import multiinputMixin from '../shared/multiinput'
import DsFormItem from '@@/components/data-input/FormItem/FormItem.vue'
/**
* Used for letting the user choose one value from a set of options.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsRadio',
mixins: [inputMixin, multiinputMixin],
components: {
DsFormItem
},
data() {
return {
pointer: 0
}
},
props: {
/**
* Whether the input should be options should be buttons
*/
buttons: {
type: Boolean,
default: false
},
/**
* The select options.
*/
options: {
type: Array,
default() {
return []
}
},
/**
* The prop to use as the label when options are objects
*/
labelProp: {
type: String,
default: 'label'
}
},
computed: {
pointerMax() {
return this.options.length - 1
}
},
watch: {
pointerMax(max) {
if (max < this.pointer) {
nextTick(() => {
this.pointer = max
})
}
}
},
methods: {
handleSelect(option) {
this.selectOption(option)
},
pointerPrev() {
if (this.pointer === 0) {
this.pointer = this.pointerMax
} else {
this.pointer--
}
this.selectPointerOption()
},
pointerNext() {
if (this.pointer === this.pointerMax) {
this.pointer = 0
} else {
this.pointer++
}
this.selectPointerOption()
},
selectPointerOption() {
this.handleSelect(this.options[this.pointer])
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,59 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-radio {
outline: none;
.ds-input-is-disabled &,
&:disabled {
color: $text-color-disabled;
opacity: $opacity-disabled;
pointer-events: none;
cursor: not-allowed;
}
}
.ds-radio-option {
&:not(.ds-button) {
@include inline-space($space-small);
}
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.ds-radio-option-mark {
display: inline-block;
position: relative;
width: $font-size-base;
height: $font-size-base;
border: $input-border-size solid $border-color-base;
background-color: $background-color-base;
border-radius: $border-radius-circle;
margin-right: $space-xx-small;
&:before {
position: absolute;
content: '';
top: 50%;
left: 50%;
transform: translateY(-50%) translateX(-50%) scale(0);
opacity: 0;
width: $font-size-x-small;
height: $font-size-x-small;
border-radius: $border-radius-circle;
background-color: $text-color-primary;
transition: all $duration-short $ease-in-sharp;
.ds-radio-option-is-selected & {
opacity: 1;
transform: translateY(-50%) translateX(-50%) scale(1);
}
}
}
.ds-radio-option-label {
font-size: $font-size-base;
cursor: pointer;
}

View File

@ -0,0 +1,434 @@
<template>
<ds-form-item>
<div
class="ds-select-wrap"
:class="[
isOpen && `ds-select-is-open`
]"
:tabindex="searchable ? -1 : tabindex"
v-click-outside="closeAndBlur"
@keydown.tab="closeAndBlur"
@keydown.self.down.prevent="pointerNext"
@keydown.self.up.prevent="pointerPrev"
@keypress.enter.prevent.stop="handleEnter"
@keyup.esc="close">
<div
v-if="icon"
class="ds-select-icon">
<ds-icon :name="icon"/>
</div>
<div
class="ds-select"
@click="openAndFocus"
:class="[
icon && `ds-select-has-icon`,
iconRight && `ds-select-has-icon-right`,
multiple && `ds-select-multiple`
]">
<div
v-if="multiple"
class="ds-selected-options">
<div
class="ds-selected-option"
v-for="(value, index) in innerValue"
:key="value[labelProp] || value">
<!-- @slot Slot to provide a custom selected option display -->
<slot
name="optionitem"
:modelValue="value">
<ds-chip
removable
@remove="deselectOption(index)"
color="primary"
:size="size">
{{ value[labelProp] || value }}
</ds-chip>
</slot>
</div>
<input
ref="search"
class="ds-select-search"
autocomplete="off"
:id="id"
:name="name ? name : model"
:autofocus="autofocus"
:placeholder="placeholder"
:tabindex="tabindex"
:disabled="disabled"
v-model="searchString"
@focus="openAndFocus"
@keydown.tab="closeAndBlur"
@keydown.delete.stop="deselectLastOption"
@keydown.down.prevent="handleKeyDown"
@keydown.up.prevent="handleKeyUp"
@keypress.enter.prevent.stop="handleEnter"
@keyup.esc="close">
</div>
<div
v-else
class="ds-select-value">
<!-- @slot Slot to provide a custom value display -->
<slot
v-if="innerValue"
name="value"
:modelValue="innerValue">
{{ innerValue[labelProp] || innerValue }}
</slot>
<div
v-else-if="placeholder"
class="ds-select-placeholder">
{{ placeholder }}
</div>
</div>
<input
v-if="!multiple"
ref="search"
class="ds-select-search"
autocomplete="off"
:id="id"
:name="name ? name : model"
:autofocus="autofocus"
:placeholder="placeholder"
:tabindex="tabindex"
:disabled="disabled"
v-model="searchString"
@focus="openAndFocus"
@keydown.tab="closeAndBlur"
@keydown.delete.stop="deselectLastOption"
@keydown.down.prevent="handleKeyDown"
@keydown.up.prevent="handleKeyUp"
@keypress.enter.prevent.stop="handleEnter"
@keyup.esc="close">
</div>
<div class="ds-select-dropdown">
<div
class="ds-select-dropdown-message"
v-if="!options || !options.length">
{{ noOptionsAvailable }}
</div>
<div
class="ds-select-dropdown-message"
v-else-if="!filteredOptions.length">
{{ noOptionsFound }} "{{ searchString }}"
</div>
<ul
class="ds-select-options"
ref="options"
v-else>
<li
class="ds-select-option"
:class="[
isSelected(option) && `ds-select-option-is-selected`,
pointer === index && `ds-select-option-hover`
]"
v-for="(option, index) in filteredOptions"
@click="handleSelect(option)"
@mouseover="setPointer(index)"
:key="option[labelProp] || option">
<!-- @slot Slot to provide custom option items -->
<slot
name="option"
:option="option">
{{ option[labelProp] || option }}
</slot>
</li>
</ul>
</div>
<div class="ds-select-icon-right">
<ds-spinner
v-if="loading"
primary
size="small"
style="position: absolute"
/>
<ds-icon
v-if="iconRight"
:name="iconRight"
/>
</div>
</div>
</ds-form-item>
</template>
<script lang="ts">
import { defineComponent, nextTick } from 'vue';
import inputMixin from '../shared/input'
import multiinputMixin from '../shared/multiinput'
import ClickOutside from 'vue-click-outside'
import DsFormItem from '@@/components/data-input/FormItem/FormItem.vue'
import DsChip from '@@/components/typography/Chip/Chip.vue'
import DsIcon from '@@/components/typography/Icon/Icon.vue'
/**
* Used for letting the user choose values from a set of options.
* @version 1.0.0
*/
export default defineComponent({
emits: ['enter'],
name: 'DsSelect',
mixins: [inputMixin, multiinputMixin],
components: {
DsFormItem,
DsChip,
DsIcon
},
directives: {
ClickOutside
},
data() {
return {
searchString: '',
pointer: 0,
isOpen: false
}
},
props: {
/**
* The placeholder shown when value is empty.
*/
placeholder: {
type: String,
default: null
},
/**
* Whether the input should be automatically focused
*/
autofocus: {
type: Boolean,
default: false
},
/**
* The name of the input's icon.
*/
icon: {
type: String,
default: null
},
/**
* The name of the input's right icon.
*/
iconRight: {
type: String,
default: 'angle-down'
},
/**
* The select options.
*/
options: {
type: Array,
default() {
return []
}
},
/**
* The prop to use as the label when options are objects
*/
labelProp: {
type: String,
default: 'label'
},
/**
* Whether the options are searchable
*/
searchable: {
type: Boolean,
default: true
},
/**
* Wheter the search string inside the inputfield should be resetted
* when selected
*/
autoResetSearch: {
type: Boolean,
default: true
},
/**
* Should a loading indicator be shown?
*/
loading: {
type: Boolean,
default: false
},
/**
* Function to filter the results
*/
filter: {
type: Function,
default: (option, searchString = '', labelProp) => {
const value = option[labelProp] || option
const searchParts = (typeof searchString === 'string') ? searchString.split(' ') : []
return searchParts.every(part => {
if (!part) {
return true
}
return value.toLowerCase().includes(part.toLowerCase())
})
}
},
/**
* Message to show when no options are available
*/
noOptionsAvailable: {
type: String,
default: 'No options available.'
},
/**
* Message to show when the search result is empty
*/
noOptionsFound: {
type: String,
default: 'No options found for:'
}
},
computed: {
filteredOptions() {
if (!this.searchString) {
return this.options
}
return this.options.filter((option) => this.filter(option, this.searchString, this.labelProp))
},
pointerMax() {
return this.filteredOptions.length - 1
}
},
watch: {
pointerMax(max) {
if (max < this.pointer) {
nextTick(() => {
this.pointer = max
})
}
},
searchString(value) {
this.setPointer(-1)
}
},
methods: {
handleSelect(options) {
if (this.pointerMax < 0) {
return
}
this.selectOption(options)
if (this.autoResetSearch || this.multiple) {
this.resetSearch()
}
if (this.multiple) {
this.$refs.search.focus()
this.handleFocus()
} else {
this.close()
}
},
resetSearch() {
this.searchString = ''
},
openAndFocus() {
this.open()
if (this.autoResetSearch) {
this.resetSearch()
}
if (!this.focus || this.multiple) {
this.$refs.search.focus()
this.handleFocus()
}
},
open() {
if (this.autoResetSearch || this.multiple) {
this.resetSearch()
}
this.isOpen = true
},
close() {
this.isOpen = false
},
closeAndBlur() {
this.close()
this.$refs.search.blur()
this.handleBlur()
},
deselectLastOption() {
if (
this.multiple &&
this.innerValue &&
this.innerValue.length &&
!this.searchString.length
) {
this.deselectOption(this.innerValue.length - 1)
}
},
handleEnter(e) {
if (this.pointer >= 0) {
this.selectPointerOption(e)
} else {
this.setPointer(-1)
this.$emit('enter', e)
}
},
handleKeyUp() {
if (!this.isOpen) {
this.open()
return
}
this.pointerPrev()
},
handleKeyDown() {
if (!this.isOpen) {
this.open()
return
}
this.pointerNext()
},
setPointer(index) {
if (!this.hadKeyboardInput) {
this.pointer = index
}
},
pointerPrev() {
if (this.pointer <= 0) {
this.pointer = this.pointerMax
} else {
this.pointer--
}
this.scrollToHighlighted()
},
pointerNext() {
if (this.pointer >= this.pointerMax) {
this.pointer = 0
} else {
this.pointer++
}
this.scrollToHighlighted()
},
scrollToHighlighted() {
clearTimeout(this.hadKeyboardInput)
if (!this.$refs.options || !this.$refs.options.children.length || this.pointerMax <= -1) {
return
}
this.hadKeyboardInput = setTimeout(() => {
this.hadKeyboardInput = null
}, 250)
this.$refs.options.children[this.pointer].scrollIntoView({
block: 'nearest'
});
},
selectPointerOption() {
this.handleSelect(this.filteredOptions[this.pointer])
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Select.vue matches snapshot 1`] = `
<dsformitem-stub>
<div
class="ds-select-wrap"
tabindex="-1"
>
<!---->
<div
class="ds-select ds-select-has-icon-right"
>
<div
class="ds-select-value"
>
1
</div>
<input
class="ds-select-search"
tabindex="0"
/>
</div>
<div
class="ds-select-dropdown"
>
<ul
class="ds-select-options"
>
<li
class="ds-select-option ds-select-option-is-selected ds-select-option-hover"
>
1
</li>
<li
class="ds-select-option"
>
2
</li>
<li
class="ds-select-option"
>
3
</li>
</ul>
</div>
<div
class="ds-select-icon-right"
>
<dsicon-stub
arialabel="icon"
name="angle-down"
tag="span"
/>
</div>
</div>
</dsformitem-stub>
`;

View File

@ -0,0 +1,312 @@
import { describe, expect, test } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import Comp from './Select.vue'
describe('Select.vue', () => {
describe('Events emitting', () => {
describe('@input', () => {
test('should be called when the value is changed passing the new value', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: '3',
options: ['1', '2', '3']
}
})
wrapper.vm.selectOption(wrapper.vm.options[0])
expect(wrapper.emitted().input[0]).toEqual(['1'])
})
test('should be called when an option is clicked passing the options value', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: '3',
options: ['1', '2', '3']
}
})
wrapper.find('.ds-select-option').trigger('click')
expect(wrapper.emitted().input[0]).toEqual(['1'])
})
})
})
describe('innerValue', () => {
test('should contain a single selected value by default', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: '1',
options: ['1', '2', '3']
}
})
expect(wrapper.vm.innerValue).toEqual('1')
})
test('should contain an array of values when multiple: true', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: ['1'],
options: ['1', '2', '3'],
multiple: true
}
})
expect(wrapper.vm.innerValue).toEqual(['1'])
})
})
describe('options', () => {
test('should highlight the selected value', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: '1',
options: ['1', '2', '3']
}
})
const option = wrapper.find('.ds-select-option')
expect(option.classes()).toContain('ds-select-option-is-selected')
})
test('should highlight all selected values when multiple: true', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: ['1', '2'],
options: ['1', '2', '3'],
multiple: true
}
})
const option = wrapper.findAll('.ds-select-option')
expect(option.at(0).classes()).toContain('ds-select-option-is-selected')
expect(option.at(1).classes()).toContain('ds-select-option-is-selected')
})
})
describe('selectOption', () => {
test('should set innerValue to selected value', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: '3',
options: ['1', '2', '3']
}
})
wrapper.vm.selectOption(wrapper.vm.options[0])
expect(wrapper.vm.innerValue).toEqual('1')
})
test('should add selected value to innerValue when multiple: true', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: ['3'],
options: ['1', '2', '3'],
multiple: true
}
})
wrapper.vm.selectOption(wrapper.vm.options[0])
expect(wrapper.vm.innerValue).toEqual(['3', '1'])
})
test('should toggle selected value in innerValue when multiple: true', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: ['3', '1'],
options: ['1', '2', '3'],
multiple: true
}
})
wrapper.vm.selectOption(wrapper.vm.options[0])
expect(wrapper.vm.innerValue).toEqual(['3'])
})
})
describe('search', () => {
test('should filter options by search string', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['cat', 'duck', 'dog']
}
})
wrapper.vm.searchString = 'do'
expect(wrapper.vm.filteredOptions).toEqual(['dog'])
})
test('should be case insensitive', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['cat', 'duck', 'dog']
}
})
wrapper.vm.searchString = 'DO'
expect(wrapper.vm.filteredOptions).toEqual(['dog'])
})
test('should ignore spaces', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['cat', 'duck', 'dog']
}
})
wrapper.vm.searchString = 'd o'
expect(wrapper.vm.filteredOptions).toEqual(['dog'])
})
test('should display filtered options', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['cat', 'duck', 'dog']
}
})
wrapper.vm.searchString = 'do'
const filteredOptions = wrapper.findAll('.ds-select-option')
expect(filteredOptions.length).toEqual(1)
})
test('should work when using search input', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['cat', 'duck', 'dog']
}
})
const searchInput = wrapper.find('.ds-select-search')
searchInput.setValue('do')
expect(wrapper.vm.filteredOptions).toEqual(['dog'])
})
})
describe('pointer', () => {
test('should be set by mouse over option', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
const options = wrapper.findAll('.ds-select-option')
options.at(2).trigger('mouseover')
expect(wrapper.vm.pointer).toEqual(2)
})
test('should be set by pointerNext', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.pointerNext()
expect(wrapper.vm.pointer).toEqual(1)
})
test('should be set to 0 by pointerNext when on last entry', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.pointer = 2
wrapper.vm.pointerNext()
expect(wrapper.vm.pointer).toEqual(0)
})
test('should be set by pointerPrev', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.pointer = 1
wrapper.vm.pointerPrev()
expect(wrapper.vm.pointer).toEqual(0)
})
test('should be set to last entry by pointerPrev when 0', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.pointerPrev()
expect(wrapper.vm.pointer).toEqual(2)
})
test('should be set by key down on wrap', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
const wrap = wrapper.find('.ds-select-wrap')
wrap.trigger('keydown.down')
expect(wrapper.vm.pointer).toEqual(1)
})
test('should be set by key up on wrap', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
const wrap = wrapper.find('.ds-select-wrap')
wrap.trigger('keydown.up')
expect(wrapper.vm.pointer).toEqual(2)
})
test('should be set by key down on search input when open', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.open()
const searchInput = wrapper.find('.ds-select-search')
searchInput.trigger('keydown.down')
expect(wrapper.vm.pointer).toEqual(1)
})
test('should be set by key up on search input when open', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.open()
const searchInput = wrapper.find('.ds-select-search')
searchInput.trigger('keydown.up')
expect(wrapper.vm.pointer).toEqual(2)
})
test('should select option by pointer value', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.pointer = 1
wrapper.vm.selectPointerOption()
expect(wrapper.vm.innerValue).toEqual('2')
})
test('should select option by enter key on wrap', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.pointer = 1
const wrap = wrapper.find('.ds-select-wrap')
wrap.trigger('keypress.enter')
expect(wrapper.vm.innerValue).toEqual('2')
})
test('should select option by enter key on search input', () => {
const wrapper = shallowMount(Comp, {
propsData: {
options: ['1', '2', '3']
}
})
wrapper.vm.pointer = 1
const searchInput = wrapper.find('.ds-select-search')
searchInput.trigger('keypress.enter')
expect(wrapper.vm.innerValue).toEqual('2')
})
})
it('matches snapshot', () => {
const wrapper = shallowMount(Comp, {
propsData: {
value: '1',
options: ['1', '2', '3']
}
})
expect(wrapper.element).toMatchSnapshot()
})
})

View File

@ -0,0 +1,167 @@
@use '../shared/input.scss' as *;
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
@include input(ds-select);
.ds-select {
user-select: none;
.ds-select-is-open & {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: none;
}
}
.ds-select-multiple {
display: flex;
align-items: center;
max-width: 100%;
}
.ds-select-search, .ds-select-value {
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: $input-border-size solid transparent;
padding: $input-padding-vertical $space-x-small;
line-height: $line-height-base;
.ds-input-size-small & {
padding: $input-padding-vertical-small $space-x-small;
}
.ds-input-size-large & {
padding: $input-padding-vertical-large $space-x-small;
}
.ds-select-has-icon & {
padding-left: $input-height;
.ds-input-size-small & {
padding-left: $input-height-small;
}
.ds-input-size-large & {
padding-left: $input-height-large;
}
}
.ds-select-has-icon-right & {
padding-right: $input-height;
.ds-input-size-small & {
padding-right: $input-height-small;
}
.ds-input-size-large & {
padding-right: $input-height-large;
}
}
}
.ds-select-search {
appearance: none;
font-size: inherit;
font-family: $font-family-text;
width: 100%;
background: transparent;
color: $text-color-base;
outline: none;
user-select: text;
opacity: 0;
&::placeholder {
color: $text-color-disabled;
}
.ds-select-is-open & {
opacity: 1;
}
.ds-select-multiple & {
position: relative;
display: inline-flex;
width: auto;
height: auto;
padding: 0;
opacity: 1;
}
}
.ds-select-placeholder, .ds-select-value {
pointer-events: none;
.ds-select-is-open & {
opacity: 0;
}
}
.ds-select-placeholder {
color: $text-color-disabled;
}
.ds-selected-options {
display: flex;
max-width: 100%;
overflow: hidden;
}
.ds-selected-option {
display: inline-flex;
align-items: center;
margin-right: $space-xx-small;
}
.ds-select-dropdown {
position: absolute;
z-index: $z-index-dropdown;
top: 100%;
left: 0;
width: 100%;
background-color: $background-color-base;
border: $input-border-size solid $border-color-active;
border-top: 0;
border-bottom-left-radius: $border-radius-base;
border-bottom-right-radius: $border-radius-base;
visibility: hidden;
opacity: 0;
transition: all $duration-short $ease-out, border-bottom 0ms;
max-height: 240px;
overflow: auto;
.ds-select-is-open & {
visibility: visible;
opacity: 1;
}
}
.ds-select-dropdown-message {
padding: $input-padding-vertical $space-x-small;
color: $text-color-disabled;
}
.ds-select-options {
@include reset-list;
}
.ds-select-option {
padding: $input-padding-vertical $space-x-small;
cursor: pointer;
transition: all $duration-short $ease-out, border-bottom 0ms;
&.ds-select-option-hover {
background-color: $background-color-softer;
color: $text-color-base;
}
}
.ds-select-option-is-selected {
background-color: $background-color-soft;
color: $text-color-primary;
}

View File

@ -0,0 +1,130 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
@mixin input ($class) {
.#{$class}-wrap {
position: relative;
}
.#{$class} {
appearance: none;
box-sizing: border-box;
font-size: $input-font-size-base;
line-height: $line-height-base;
font-family: $font-family-text;
width: 100%;
padding: $input-padding-vertical $space-x-small;
height: $input-height;
color: $text-color-base;
background: $background-color-soft;
border: $input-border-size solid $border-color-softer;
border-radius: $border-radius-base;
outline: none;
transition: all $duration-short $ease-out;
&::placeholder {
color: $text-color-disabled;
}
.ds-input-has-focus &,
&:focus {
border-color: $border-color-active;
background: $background-color-base;
}
.ds-input-is-disabled &,
&:disabled {
color: $text-color-disabled;
opacity: $opacity-disabled;
pointer-events: none;
cursor: not-allowed;
background-color: $background-color-disabled;
}
.ds-input-is-readonly & {
pointer-events: none;
}
.ds-input-has-error & {
border-color: $border-color-danger;
}
}
.ds-input-size-small {
font-size: $font-size-small;
.#{$class} {
font-size: $input-font-size-small;
height: $input-height-small;
padding: $input-padding-vertical-small $space-x-small;
}
}
.ds-input-size-large {
font-size: $font-size-large;
.#{$class} {
font-size: $input-font-size-large;
height: $input-height-large;
padding: $input-padding-vertical-large $space-x-small;
}
}
.#{$class}-icon,
.#{$class}-icon-right {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: $input-height;
color: $text-color-softer;
transition: color $duration-short $ease-out;
pointer-events: none;
.ds-input-has-focus & {
color: $text-color-base;
}
.ds-input-size-small & {
width: $input-height-small;
}
.ds-input-size-large & {
width: $input-height-large;
}
}
.#{$class}-icon-right {
right: 0;
left: auto;
}
.#{$class}-has-icon {
padding-left: $input-height;
.ds-input-size-small & {
padding-left: $input-height-small;
}
.ds-input-size-large & {
padding-left: $input-height-large;
}
}
.#{$class}-has-icon-right {
padding-right: $input-height;
.ds-input-size-small & {
padding-right: $input-height-small;
}
.ds-input-size-large & {
padding-right: $input-height-large;
}
}
}

View File

@ -0,0 +1,179 @@
import { getProperty } from 'dot-prop'
import Schema from 'async-validator'
/**
* @mixin
*/
export default {
inject: {
$parentForm: {
default: null
}
},
provide() {
return {
$parentInput: this
}
},
props: {
/**
* The value of the input. Can be passed via v-model.
*/
value: {
type: [String, Object, Number, Array],
default: null
},
/**
* The model name when used within a form component. Uses dot notation.
*/
model: {
type: String,
default: null
},
/**
* Name to use on the input for accessibility
*/
name: {
type: String,
default: null
},
/**
* The label of the input.
*/
label: {
type: String,
default: null
},
/**
* The id of the input.
*/
id: {
type: String,
default: null
},
/**
* Whether the input is disabled or not.
*/
disabled: {
type: Boolean,
default: false
},
/**
* Whether the input should be read-only
*/
readonly: {
type: Boolean,
default: false
},
/**
* The async-validator schema used for the input.
* @default null
*/
schema: {
type: Object,
default: () => null
},
/**
* The input's size.
* @options small|base|large
*/
size: {
type: String,
default: 'base',
validator: value => {
return value.match(/(small|base|large)/)
}
},
tabindex: {
type: Number,
default: 0
}
},
data() {
return {
innerValue: null,
error: null,
focus: false
}
},
computed: {
stateClasses() {
return [
this.size && `ds-input-size-${this.size}`,
this.disabled && 'ds-input-is-disabled',
this.readonly && 'ds-input-is-readonly',
this.error && 'ds-input-has-error',
this.focus && 'ds-input-has-focus'
]
}
},
watch: {
value: {
handler(value) {
this.innerValue = value
},
deep: true,
immediate: true
}
},
created() {
if (this.$parentForm && this.model) {
this.$parentForm.subscribe(this.handleFormUpdate)
}
},
beforeDestroy() {
if (this.$parentForm && this.model) {
this.$parentForm.unsubscribe(this.handleFormUpdate)
}
},
methods: {
handleInput(event) {
this.input(event.target.value)
},
input(value) {
this.innerValue = value
if (this.$parentForm && this.model) {
this.$parentForm.update(this.model, value)
} else {
/**
* Fires after user input.
* Receives the value as the only argument.
*
* @event input
*/
this.$emit('update:modelValue', value)
this.validate(value)
}
},
handleFormUpdate(data, errors) {
this.innerValue = getProperty(data, this.model)
this.error = errors ? errors[this.model] : null
},
validate(value) {
if (!this.schema) {
return
}
const validator = new Schema({ input: this.schema })
// Prevent validator from printing to console
// eslint-disable-next-line
const warn = console.warn;
// eslint-disable-next-line
console.warn = () => {};
validator.validate({ input: value }, errors => {
if (errors) {
this.error = errors[0].message
} else {
this.error = null
}
// eslint-disable-next-line
console.warn = warn;
})
},
handleFocus() {
this.focus = true
},
handleBlur() {
this.focus = false
}
}
}

View File

@ -0,0 +1,47 @@
/**
* @mixin
*/
export default {
props: {
/**
* Whether the user can select multiple items
*/
multiple: {
type: Boolean,
default: false
}
},
methods: {
selectOption(option) {
if (this.multiple) {
this.selectMultiOption(option)
} else {
this.input(option)
}
},
selectMultiOption(value) {
if (!this.innerValue) {
return this.input([value])
}
const index = this.innerValue.indexOf(value)
if (index < 0) {
return this.input([...this.innerValue, value])
}
this.deselectOption(index)
},
deselectOption(index) {
const newArray = [...this.innerValue]
newArray.splice(index, 1)
this.input(newArray)
},
isSelected(option) {
if (!this.innerValue) {
return false
}
if (this.multiple) {
return this.innerValue.includes(option)
}
return this.innerValue === option
}
}
}

View File

@ -0,0 +1,65 @@
import Avatar from './data-display/Avatar/Avatar.vue'
import CopyField from './data-display/CopyField/CopyField.vue'
import List from './data-display/List/List.vue'
import Number from './data-display/Number/Number.vue'
import Table from './data-display/Table/Table.vue'
import Form from './data-input/Form/Form.vue'
import FormItem from './data-input/FormItem/FormItem.vue'
import Input from './data-input/Input/Input.vue'
import Radio from './data-input/Radio/Radio.vue'
import Select from './data-input/Select/Select.vue'
import Container from './layout/Container/Container.vue'
import Card from './layout/Card/Card.vue'
import Flex from './layout/Flex/Flex.vue'
import FlexItem from './layout/Flex/FlexItem.vue'
import Grid from './layout/Grid/Grid.vue'
import Modal from './layout/Modal/Modal.vue'
import Page from './layout/Page/Page.vue'
import PageTitle from './layout/PageTitle/PageTitle.vue'
import Placeholder from './layout/Placeholder/Placeholder.vue'
import Section from './layout/Section/Section.vue'
import Space from './layout/Space/Space.vue'
import Spinner from './layout/Spinner/Spinner.vue'
import Button from './navigation/Button/Button.vue'
import Menu from './navigation/Menu/Menu.vue'
import Chip from './typography/Chip/Chip.vue'
import Code from './typography/Code/Code.vue'
import Heading from './typography/Heading/Heading.vue'
import Icon from './typography/Icon/Icon.vue'
import Logo from './typography/Logo/Logo.vue'
import Tag from './typography/Tag/Tag.vue'
import Text from './typography/Text/Text.vue'
export {
Avatar,
CopyField,
List,
Number,
Table,
Form,
FormItem,
Input,
Radio,
Select,
Container,
Card,
Flex,
FlexItem,
Grid,
Modal,
Page,
PageTitle,
Placeholder,
Section,
Space,
Spinner,
Button,
Menu,
Chip,
Code,
Heading,
Icon,
Logo,
Tag,
Text,
}

View File

@ -0,0 +1,167 @@
<template>
<component
:is="tag"
class="ds-card"
:class="[
$slots.image && 'ds-card-has-image',
primary && `ds-card-primary`,
secondary && `ds-card-secondary`,
centered && `ds-card-centered`,
hover && `ds-card-hover`,
space && `ds-card-space-${space}`
]">
<div
class="ds-card-image"
v-if="image || $slots.image">
<!-- @slot Content of the card's image -->
<slot
name="image"
:image="image">
<img
:src="image"
v-if="!error"
@error="onError" >
</slot>
</div>
<div
class="ds-card-icon"
v-if="icon">
<ds-icon :name="icon"/>
</div>
<header
class="ds-card-header"
v-if="header || $slots.header">
<!-- @slot Content of the card's header -->
<slot name="header">
<ds-heading
:tag="headerTag"
size="h3">{{ header }}</ds-heading>
</slot>
</header>
<div class="ds-card-content">
<template>
<slot />
</template>
</div>
<footer
class="ds-card-footer"
v-if="$slots.footer">
<!-- @slot Content of the card's footer -->
<slot name="footer"/>
</footer>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import DsIcon from '@@/components/typography/Icon/Icon.vue'
import DsHeading from '@@/components/typography/Heading/Heading.vue'
/**
* A card is used to group content in an appealing way.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsCard',
components: {
DsIcon,
DsHeading,
},
props: {
/**
* The outtermost html tag
*/
tag: {
type: String,
default: 'article'
},
/**
* The card's header
*/
header: {
type: String,
default: null
},
/**
* The card's header tag
* @options h1|h2|h3|h4|h5|h6
*/
headerTag: {
type: String,
default: 'h3',
validator: value => {
return value.match(/(h1|h2|h3|h4|h5|h6)/)
}
},
/**
* The card's image
*/
image: {
type: String,
default: null
},
/**
* The card's icon
*/
icon: {
type: String,
default: null
},
/**
* Highlight with primary color
*/
primary: {
type: Boolean,
default: false
},
/**
* Highlight with secondary color
*/
secondary: {
type: Boolean,
default: false
},
/**
* Center the content
*/
centered: {
type: Boolean,
default: false
},
/**
* Make the card hoverable
*/
hover: {
type: Boolean,
default: false
},
/**
* If you need some spacing you can provide it here like for ds-space
* @options small|large|x-large|xx-large
*/
space: {
type: String,
default: null,
validator: value => {
return value.match(/(small|large|x-large|xx-large)/)
}
}
},
data() {
return {
error: false
}
},
methods: {
onError() {
this.error = true
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,113 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
$border-radius: $border-radius-x-large;
.ds-card {
@include reset;
@include border-radius($border-radius);
display: flex;
flex-direction: column;
background-color: $background-color-base;
color: $text-color-base;
box-shadow: $box-shadow-base;
height: 100%;
}
.ds-card-centered {
text-align: center;
}
.ds-card-hover {
transform: translateY(0);
transition:
transform $duration-base $ease-out,
background $duration-base $ease-out,
box-shadow $duration-base $ease-out;
&:hover {
// transform: translateY(-$space-x-small);
box-shadow: $box-shadow-x-large;
}
}
.ds-card-image {
@include border-radius($border-radius, 'top');
overflow: hidden;
img {
display: block;
width: 100%;
max-width: 100%;
}
}
.ds-card-icon {
padding: $space-base $space-base 0 $space-base;
font-size: $font-size-xxxx-large;
opacity: $opacity-soft;
}
.ds-card-header {
@include reset;
@include border-radius($border-radius, 'top');
padding: $space-base $space-base $space-xxx-small $space-base;
.ds-card-has-image & {
@include border-radius(0, 'top');
}
}
.ds-card-content {
@include reset;
padding: $space-x-small $space-base;
flex: 1 1 0;
&:last-child:not(:only-child) {
padding-bottom: $space-large;
}
}
.ds-card-footer {
@include reset;
padding: $space-base;
border-radius: 0 0 $border-radius $border-radius;
overflow: hidden;
}
// Color variants
.ds-card-primary {
background-color: $background-color-primary;
color: $text-color-primary-inverse;
&.ds-card-hover:hover {
background-color: $background-color-primary-active;
}
}
.ds-card-secondary {
background-color: $background-color-secondary;
color: $text-color-secondary-inverse;
&.ds-card-hover:hover {
background-color: $background-color-secondary-active;
}
}
.ds-card-space-small {
padding-top: $space-small;
padding-bottom: $space-small;
}
.ds-card-space-large {
padding-top: $space-large;
padding-bottom: $space-large;
}
.ds-card-space-x-large {
padding-top: $space-x-large;
padding-bottom: $space-x-large;
}
.ds-card-space-xx-large {
padding-top: $space-xx-large;
padding-bottom: $space-xx-large;
}

View File

@ -0,0 +1,57 @@
<template>
<component
:is="tag"
class="ds-container"
:class="[
`ds-container-${width}`,
centered && `ds-container-centered`,
]"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* This component is used as a wrapper for the page's content.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsContainer',
props: {
/**
* The outtermost html tag
*/
tag: {
type: String,
default: 'div'
},
/**
* The maximum width the container will take.
* The widths correspond to the different media breakpoints.
* @options x-small|small|medium|large|x-large
*/
width: {
type: String,
default: 'x-large',
validator: value => {
return value.match(/(x-small|small|medium|large|x-large)/)
}
},
/**
* Center the content
*/
centered: {
type: Boolean,
default: false
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,40 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-container {
@include reset;
padding: 0 $space-small;
margin: 0 auto;
@media #{$media-query-medium} {
padding: 0 $space-base;
}
@media #{$media-query-large} {
padding: 0 $space-x-large;
}
}
.ds-container-centered {
text-align: center;
}
.ds-container-x-small {
max-width: #{$xs}px;
}
.ds-container-small {
max-width: #{$sm}px;
}
.ds-container-medium {
max-width: #{$md}px;
}
.ds-container-large {
max-width: #{$lg}px;
}
.ds-container-x-large {
max-width: #{$xl}px;
}

View File

@ -0,0 +1,94 @@
<template>
<component
:is="tag"
:style="styles"
class="ds-flex">
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { getSpace } from '@@/utils'
import { mediaQuery } from '@@/mixins'
/**
* Used in combination with the flex item component to create flexible layouts.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsFlex',
mixins: [mediaQuery],
provide() {
return {
$parentFlex: this
}
},
props: {
/**
* Default gutter size of columns
*/
gutter: {
type: [String, Object],
default: null
},
/**
* Default width of columns
*/
width: {
type: [String, Number, Object],
default: 1
},
/**
* Direction of the flex items
* @options row|row-reverse|column|column-reverse
*/
direction: {
type: [String, Object],
default: null
},
/**
* The outtermost html tag
*/
tag: {
type: String,
default: 'div'
}
},
computed: {
styles() {
const gutter = this.mediaQuery(this.gutter)
const direction = this.mediaQuery(this.direction)
const gutterStyle = gutter ? this.parseGutter(gutter) : {}
const directionStyle = direction ? this.parseDirection(direction) : {}
return {
...gutterStyle,
...directionStyle
}
}
},
methods: {
parseGutter(gutter: string) {
const realGutter = getSpace(gutter)
return {
marginLeft: `-${realGutter / 2}px`,
marginRight: `-${realGutter / 2}px`
}
},
parseDirection(direction: string) {
return {
flexDirection: direction
}
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,107 @@
<template>
<component
:is="tag"
:style="styles"
class="ds-flex-item">
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { getSpace } from '@@/utils'
import { mediaQuery } from '@@/mixins'
/**
* @version 1.0.0
* @see DsFlex
*/
export default defineComponent({
name: 'DsFlexItem',
mixins: [mediaQuery],
inject: {
$parentFlex: {
default: null
}
},
props: {
/**
* The item's width
* @default 1
*/
width: {
type: [String, Number, Object],
default() {
return this.$parentFlex ? this.$parentFlex.width : 1
}
},
/**
* The outtermost html tag
*/
tag: {
type: String,
default: 'div'
},
/**
* Center content vertical and horizontal
*/
centered: {
type: Boolean,
default: false
}
},
computed: {
gutter() {
return this.$parentFlex ? this.$parentFlex.gutter : 0
},
styles() {
const width = this.mediaQuery(this.width)
const gutter = this.mediaQuery(this.gutter)
const widthStyle = this.parseWidth(width)
const gutterStyle = this.parseGutter(gutter)
const centerStyle = this.centered
? {
'align-self': 'center',
'jusify-self': 'center'
}
: {}
return {
...widthStyle,
...gutterStyle,
...centerStyle
}
}
},
methods: {
parseWidth(width: string | number) {
const styles = {}
if (isNaN(width)) {
styles.flexBasis = width
styles.width = width
} else {
styles.flexGrow = width
styles.flexShrink = 0
styles.flexBasis = 0
}
return styles
},
parseGutter(gutter: string) {
const realGutter = getSpace(gutter)
if (realGutter === 0) {
return {}
}
return {
paddingLeft: `${realGutter / 2}px`,
paddingRight: `${realGutter / 2}px`,
marginBottom: `${realGutter}px`
}
}
},
});
</script>

View File

@ -0,0 +1,12 @@
@use "@@/styles/shared" as *;
.ds-flex {
@include reset;
display: flex;
flex-wrap: wrap;
}
.ds-flex-item {
@include reset;
@include layout-flex-fix;
}

View File

@ -0,0 +1,73 @@
<template>
<component
:is="tag"
class="ds-grid"
:style="styles"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { getSpace } from '@@/utils'
/**
* Used in combination with the grid item component to create masonry layouts.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsGrid',
props: {
/**
* The vertical and horizontal gap between grid items
* @options xxx-small|xx-small|x-small|small|base|large|x-large|xx-large|xxx-large
*/
gap: {
type: String,
default: 'small',
validator: value => (
value.match(/(xxx-small|xx-small|x-small|small|base|large|x-large|xx-large|xxx-large)/)
)
},
/**
* The minimum width of each column
*/
minColumnWidth: {
type: Number,
default: 250,
},
/**
* The height of each row (recommended to use the default)
*/
rowHeight: {
type: Number,
default: 20,
},
/**
* The outermost html tag
*/
tag: {
type: String,
default: 'div',
},
},
computed: {
styles() {
return {
gridTemplateColumns: `repeat(auto-fill, minmax(${this.minColumnWidth}px, 1fr))`,
gridGap: `${getSpace(this.gap)}px`,
gridAutoRows: `${this.rowHeight}px`,
}
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,55 @@
<template>
<component
:is="tag"
:style="styles"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* @version 1.0.0
* @see DsGrid
*/
export default defineComponent({
name: 'DsGridItem',
props: {
/**
* The number of columns the item will span
* @options 'fullWidth'|number
*/
columnSpan: {
type: [String, Number],
default: 1,
},
/**
* The number of rows the item will span
*/
rowSpan: {
type: Number,
default: 4,
},
/**
* The outermost html tag
*/
tag: {
type: String,
default: 'div',
},
},
computed: {
styles() {
return {
gridRowEnd: `span ${this.rowSpan}`,
gridColumnStart: this.columnSpan === 'fullWidth' ? 1 : 'auto',
gridColumnEnd: this.columnSpan === 'fullWidth' ? -1 : `span ${this.columnSpan}`,
}
}
},
});
</script>

View File

@ -0,0 +1,3 @@
.ds-grid {
display: grid;
}

View File

@ -0,0 +1,195 @@
<template>
<div>
<div
:key="key"
class="ds-modal-wrapper">
<transition
name="ds-transition-fade"
appear>
<div
v-if="isOpen"
class="ds-modal-backdrop"
ref="backdrop"
@click="backdropHandler"
>
&nbsp;
</div>
</transition>
<transition
name="ds-transition-modal-appear"
appear>
<ds-card
v-if="isOpen"
class="ds-modal"
:class="[extended && 'ds-modal-extended']"
:header="title"
tableindex="-1"
role="dialog"
ref="modal"
style="display: block"
>
<ds-button
v-if="!force"
class="ds-modal-close"
ghost
size="small"
icon="close"
aria-hidden="true"
@click="cancel('close')"
/>
<!-- @slot Modal content -->
<slot ref="modalBody"/>
<template slot="footer">
<!-- @slot Modal footer with action buttons -->
<slot
name="footer"
:confirm="confirm"
:cancel="cancel"
:cancelLabel="cancelLabel"
:confirmLabel="confirmLabel"
>
<ds-button
ghost
icon="close"
@click.prevent="cancel('cancel')">{{ cancelLabel }}</ds-button>
<ds-button
primary
icon="check"
@click.prevent="confirm('confirm')">{{ confirmLabel }}</ds-button>
</slot>
</template>
</ds-card>
</transition>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/* eslint-disable no-empty */
/**
* Simple Modal Component
* @version 1.0.0
*/
export default defineComponent({
emits: ['opened', 'confirm', 'cancel', 'update:isOpen', 'close'],
name: 'DsModal',
props: {
/**
* Modal title
*/
title: {
type: String,
default: null
},
/**
* Open state
*/
isOpen: {
type: Boolean,
default: false
},
/**
* Force user input by disabeling the ESC key, close button and click on the backdrop
*/
force: {
type: Boolean,
default: false
},
/**
* Allow closing without choosing action by ESC key, close button or click on the backdrop
*/
extended: {
type: Boolean,
default: false
},
/**
* Cancel button label
*/
cancelLabel: {
type: String,
default: 'Cancel'
},
/**
* Confirm button label
*/
confirmLabel: {
type: String,
default: 'Confirm'
}
},
model: {
prop: 'isOpen',
event: 'update:isOpen'
},
watch: {
isOpen: {
immediate: true,
handler(show) {
try {
if (show) {
this.$emit('opened')
document.getElementsByTagName('body')[0].classList.add('modal-open')
} else {
document
.getElementsByTagName('body')[0]
.classList.remove('modal-open')
}
} catch (err) {}
}
}
},
methods: {
confirm(type = 'confirm') {
this.$emit('confirm')
this.close(type)
},
cancel(type = 'cancel') {
this.$emit('cancel')
this.close(type)
},
close(type) {
this.$emit('update:isOpen', false)
this.$emit('close', type)
},
backdropHandler() {
if (!this.force) {
this.cancel('backdrop')
}
}
},
beforeCreate() {
// create random key string
this.key = Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.substr(0, 5)
},
mounted() {
const keydownListener = document.addEventListener('keydown', e => {
if (this.isOpen && !this.force && e.keyCode === 27) {
this.cancel('backdrop')
}
})
this.$once('hook:beforeDestroy', () => {
document.removeEventListener('keydown', keydownListener)
})
if (this.isOpen) {
this.$emit('opened')
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,103 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-modal-wrapper {
padding: $space-base;
position: relative;
}
.ds-modal {
position: fixed;
z-index: $z-index-modal;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
display: flex;
flex-direction: column;
max-width: 400px;
width: calc(90vw - 40px);
height: auto;
max-height: 90vh;
box-shadow: $box-shadow-x-large;
&.ds-modal-extended {
max-width: 600px;
}
}
.ds-modal .ds-card-header {
position: relative;
&::after {
content: "";
height: 30px;
background: linear-gradient(rgba(255,255,255,1), rgba(255,255,255,0));
position: absolute;
width: calc(100% - 10px);
bottom: -30px;
left: 0;
pointer-events: none;
z-index: 1;
}
}
.ds-modal-close {
position: absolute;
top: $space-small;
right: $space-small;
}
.ds-modal .ds-card-content {
flex: 1;
overflow-y: auto;
height: auto;
min-height: 50px;
max-height: 50vh;
padding-bottom: $space-large !important;
}
.ds-modal footer {
position: relative;
display: flex;
overflow: visible;
flex-shrink: 0;
justify-content: flex-end;
background-color: $background-color-softer;
padding: $space-small;
& > button {
margin-left: $space-x-small;
}
&::before {
content: "";
height: 45px;
background: linear-gradient(rgba(255,255,255,0), rgba(255,255,255,.9));
position: absolute;
width: calc(100% - 10px);
z-index: 1;
left: 0;
top: -45px;
pointer-events: none;
}
}
.ds-modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: $z-index-modal - 1;
background: rgba(0, 0, 0, 0.7);
}
.ds-transition-modal-appear-enter-active {
opacity: 1;
transition: all 200ms $ease-out-bounce;
transform: translate3d(-50%, -50%, 0) scale(1);
}
.ds-transition-modal-appear-enter,
.ds-transition-modal-appear-leave-active {
opacity: 0;
transform: translate3d(-50%, -50%, 0) scale(0.8);
}

View File

@ -0,0 +1,92 @@
<template>
<div
class="ds-page"
:class="[
hasHeader ? 'ds-page-has-header' : 'ds-page-has-no-header',
$slots.sidebar && 'ds-page-has-sidebar',
showDrawer && 'ds-page-show-drawer',
contained && 'ds-page-is-contained'
]"
>
<header
class="ds-page-header">
<div class="ds-page-header-container">
<div class="ds-page-brand">
<!-- @slot Content of the page's brand -->
<slot name="brand"/>
</div>
<div class="ds-page-navbar">
<!-- @slot Content of the navbar -->
<slot name="navbar"/>
</div>
<div
v-if="$slots.drawer"
class="ds-page-navigation-toggle"
@click="showDrawer = !showDrawer">
<ds-icon name="bars"/>
</div>
</div>
</header>
<aside
v-if="$slots.sidebar"
class="ds-page-sidebar">
<div class="ds-page-sidebar-content">
<!-- @slot Content of the sidebar -->
<slot name="sidebar" />
</div>
</aside>
<aside
v-if="$slots.drawer"
class="ds-page-drawer">
<!-- @slot Content of the drawer (mobile navigation) -->
<slot name="drawer" />
</aside>
<main class="ds-page-content">
<slot />
</main>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* This component is used to layout a page.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsPage',
props: {
/**
* Whether the layout should have a maximum width
*/
contained: {
type: Boolean,
default: false
}
},
data() {
return {
showDrawer: false
}
},
computed: {
hasHeader() {
return this.$slots.navbar
}
},
methods: {
closeDrawer() {
this.showDrawer = false
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,196 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
$contained-width: 1400px;
$header-height: 54px;
$header-background-color: $background-color-soft;
$sidebar-brand-height: 136px;
$sidebar-width: 220px;
$sidebar-width-large: 260px;
$sidebar-background-color: $background-color-base;
$drawer-background-color: $background-color-base;
.ds-page {
@include reset;
@include clearfix;
background: $background-color-base;
min-height: 100vh;
&.ds-page-is-contained {
max-width: $contained-width;
width: 100%;
margin: 0 auto;
}
}
.ds-page-header {
@include reset;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: $z-index-page-header;
.ds-page-has-no-header & {
@media #{$media-query-medium} {
right: auto;
width: $sidebar-width;
}
@media #{$media-query-large} {
width: $sidebar-width-large;
}
}
}
.ds-page-header-container {
height: $header-height;
background: $header-background-color;
box-shadow: $box-shadow-small;
display: flex;
justify-content: space-between;
.ds-page-is-contained & {
max-width: $contained-width;
margin: 0 auto;
}
.ds-page-has-no-header & {
@media #{$media-query-medium} {
height: $sidebar-brand-height;
display: block;
background: $sidebar-background-color;
box-shadow: none;
}
}
}
.ds-page-brand {
@include reset;
height: 100%;
display: flex;
align-items: center;
padding: 0 $space-small;
.ds-page-has-no-header & {
@media #{$media-query-medium} {
height: 100%;
justify-content: center;
}
}
}
.ds-page-navbar {
display: none;
@media #{$media-query-medium} {
display: block;
}
}
.ds-page-navigation-toggle {
height: 100%;
display: flex;
align-items: center;
padding: 0 $space-small;
color: $text-color-link;
cursor: pointer;
&:hover {
color: $text-color-link-active;
}
@media #{$media-query-medium} {
display: none;
}
}
.ds-page-sidebar {
@include reset;
position: fixed;
top: $header-height;
bottom: 0;
width: $sidebar-width;
z-index: $z-index-page-sidebar;
background-color: $sidebar-background-color;
box-shadow: $box-shadow-base;
display: none;
@media #{$media-query-medium} {
display: block;
}
@media #{$media-query-large} {
width: $sidebar-width-large;
}
.ds-page-has-no-header & {
@media #{$media-query-medium} {
top: 0;
}
}
}
.ds-page-sidebar-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
.ds-page-has-no-header & {
@media #{$media-query-medium} {
top: $sidebar-brand-height;
}
}
}
.ds-page-drawer {
@include reset;
position: fixed;
left: 0;
top: $header-height;
bottom: 0;
overflow-y: auto;
width: 100%;
z-index: $z-index-page-sidebar;
background-color: $drawer-background-color;
transform: translateX(-100%);
opacity: 0;
transition: opacity $duration-long $ease-out-sharp,
transform $duration-long $ease-out-sharp;
.ds-page-show-drawer & {
opacity: 1;
transform: translateX(0);
}
@media #{$media-query-medium} {
display: none;
}
}
.ds-page-content {
@include reset;
margin-top: $header-height;
.ds-page-has-no-header & {
@media #{$media-query-medium} {
margin-top: 0;
}
}
.ds-page-has-sidebar & {
@media #{$media-query-medium} {
padding-left: $sidebar-width;
}
@media #{$media-query-large} {
padding-left: $sidebar-width-large;
}
}
}

View File

@ -0,0 +1,58 @@
<template>
<component
:is="tag"
class="ds-page-title"
:class="[
highlight && `ds-page-title-highlight`
]"
>
<ds-container>
<ds-heading>
{{ heading }}
</ds-heading>
<slot />
</ds-container>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* This component is used as the title of a page.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsPageTitle',
props: {
/**
* The heading of the page.
*/
heading: {
type: String,
default: '',
required: true
},
/**
* Whether this title should be highlighted
* `true, false`
*/
highlight: {
type: Boolean,
default: false
},
/**
* The html element name used for the title.
*/
tag: {
type: String,
default: 'header'
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,18 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-page-title {
@include reset;
padding: $space-large 0;
color: $text-color-primary;
@include gradient-pattern($background-color-soft, $background-color-softer);
@media #{$media-query-medium} {
padding: $space-x-large 0;
}
}
.ds-page-title-highlight {
color: $text-color-primary-inverse;
@include gradient-pattern($background-color-primary-active, $background-color-primary);
}

View File

@ -0,0 +1,35 @@
<template>
<component
:is="tag"
class="ds-placeholder">
<div class="ds-placeholder-content">
<slot />
</div>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* This component is used as a placeholder for other content.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsPlaceholder',
props: {
/**
* The html element name used for the placeholder.
*/
tag: {
type: String,
default: 'div'
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,17 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-placeholder {
@include reset;
height: 100%;
padding: $space-base;
display: flex;
align-items: center;
justify-content: center;
background-color: $background-color-softer;
border: $border-size-base dashed $border-color-base;
@media #{$media-query-medium} {
padding: $space-x-large 0;
}
}

View File

@ -0,0 +1,72 @@
<template>
<component
:is="tag"
class="ds-section"
:class="[
fullheight && `ds-section-fullheight`,
primary && `ds-section-primary`,
secondary && `ds-section-secondary`,
centered && `ds-section-centered`
]"
>
<div class="ds-section-content">
<ds-container>
<slot />
</ds-container>
</div>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* A section is used to group bigger chunks of related content.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsSection',
props: {
/**
* Whether this section should be fullheight
*/
fullheight: {
type: Boolean,
default: false
},
/**
* Highlight with primary color
*/
primary: {
type: Boolean,
default: false
},
/**
* Highlight with secondary color
*/
secondary: {
type: Boolean,
default: false
},
/**
* Center the content
*/
centered: {
type: Boolean,
default: false
},
/**
* The html element name used for the section.
*/
tag: {
type: String,
default: 'section'
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,36 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-section {
@include reset;
padding: $space-large 0;
background-color: $background-color-soft;
@media #{$media-query-medium} {
padding: $space-x-large 0;
}
}
.ds-section-centered {
text-align: center;
}
.ds-section-primary {
color: $text-color-primary-inverse;
@include gradient-pattern($background-color-primary-active, $background-color-primary);
}
.ds-section-secondary {
color: $text-color-secondary-inverse;
@include gradient-pattern($background-color-secondary-active, $background-color-secondary);
}
.ds-section-fullheight {
min-height: 100vh;
display: flex;
align-items: center;
}
.ds-section-content {
flex: 0 0 100%;
}

View File

@ -0,0 +1,119 @@
<template>
<component
:is="tag"
:style="styles"
class="ds-space"
:class="[
centered && 'ds-space-centered'
]"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { getSpace } from '@@/utils'
import { mediaQuery } from '@@/mixins'
/**
* Use this component for grouping and separation.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsSpace',
mixins: [mediaQuery],
inject: {
$parentRow: {
default: null
}
},
props: {
/**
* The top margin of this space.
*/
marginTop: {
type: [String, Object],
default: null
},
/**
* The bottom margin of this space.
*/
marginBottom: {
type: [String, Object],
default: 'large'
},
/**
* The bottom and top margin of this space.
*/
margin: {
type: [String, Object],
default: null
},
/**
* Center content vertacally and horizontally
*/
centered: {
type: Boolean,
default: false
},
/**
* The html element name used for this space.
*/
tag: {
type: String,
default: 'div'
}
},
computed: {
styles() {
const top = this.margin ? this.margin : this.marginTop
const bottom = this.margin ? this.margin : this.marginBottom
const marginTop = this.mediaQuery(top)
const marginBottom = this.mediaQuery(bottom)
const marginTopStyle = this.parseMargin('Top')(marginTop)
const marginBottomStyle = this.parseMargin('Bottom')(marginBottom)
const centerStyle = this.centered
? {
'text-align': 'center',
flex: 1,
'align-content': 'center',
'jusify-content': 'center'
}
: {}
return {
...marginTopStyle,
...marginBottomStyle,
...centerStyle
}
}
},
methods: {
parseMargin(direction) {
return margin => {
const styles = {}
if (!margin) {
return styles
}
const realMargin = getSpace(margin)
if (realMargin !== 0) {
styles[`margin${direction}`] = `${realMargin}px`
}
return styles
}
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,9 @@
@use "@@/styles/shared" as *;
.ds-space {
@include reset;
}
.ds-space-centered {
text-align: center;
}

View File

@ -0,0 +1,75 @@
<template>
<svg
viewBox="0 0 50 50"
class="ds-spinner"
:class="[
`ds-size-${this.size}`,
inverse && 'ds-spinner-inverse',
primary && !inverse && `ds-spinner-primary`,
secondary && !inverse && `ds-spinner-secondary`,
danger && !inverse && `ds-spinner-danger`,
]"
>
<circle
class="ds-spinner-circle"
cx="25"
cy="25"
r="20"
fill="none"
stroke-width="5"/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'DsSpinner',
props: {
/**
* The size used for the spinner.
* @options small|base|large
*/
size: {
type: String,
default: 'base',
validator: value => {
return value.match(/(small|base|large)/)
}
},
/**
* Set to true, if you use it on dark background
*/
inverse: {
type: Boolean,
default: false
},
/**
* Primary style
*/
primary: {
type: Boolean,
default: false
},
/**
* Secondary style
*/
secondary: {
type: Boolean,
default: false
},
/**
* Danger style
*/
danger: {
type: Boolean,
default: false
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,67 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
$size-small: $space-base;
$size-base: $space-large;
$size-large: $space-x-large;
.ds-spinner {
animation: rotate 16s linear infinite;
position: relative;
display: inline-block;
width: $size-base;
height: $size-base;
&.ds-size-small {
width: $size-small;
height: $size-small;
}
&.ds-size-large {
width: $size-large;
height: $size-large;
}
}
.ds-spinner-circle {
stroke: $text-color-soft;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
.ds-spinner-inverse & {
stroke: $color-primary-inverse;
}
.ds-spinner-primary & {
stroke: $text-color-primary;
}
.ds-spinner-secondary & {
stroke: $text-color-secondary;
}
.ds-spinner-danger & {
stroke: $text-color-danger;
}
}
@keyframes rotate {
100% {
transform: rotate(2160deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}

View File

@ -0,0 +1,192 @@
<template>
<component
@click.capture="handleClick"
class="ds-button"
:class="[
size && `ds-button-size-${size}`,
primary && `ds-button-primary`,
secondary && `ds-button-secondary`,
danger && `ds-button-danger`,
ghost && `ds-button-ghost`,
iconOnly && `ds-button-icon-only`,
hover && `ds-button-hover`,
fullwidth && `ds-button-fullwidth`,
loading && `ds-button-loading`,
right && `ds-button-right`
]"
:name="name"
v-bind="bindings"
:is="linkTag">
<div class="ds-button-wrap">
<ds-icon
v-if="icon"
:name="icon"
/>
<span
class="ds-button-text"
v-if="$slots.default">
<slot />
</span>
</div>
<ds-spinner
v-if="loading"
:inverse="!ghost && (primary || secondary || danger)"
/>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Used to provide actions or navigation.
* @version 1.0.0
*/
export default defineComponent({
emits: ['click'],
name: 'DsButton',
props: {
/**
* The path of this button. Can be a url or a Vue router path object.
*/
path: {
type: [String, Object],
default() {
return null
}
},
/**
* The size used for the text.
* @options small|base|large
*/
size: {
type: String,
default: null,
validator: value => {
return value.match(/(small|base|large)/)
}
},
/**
* The component / tag used for this button
* @options router-link|a|button
*/
linkTag: {
type: String,
default() {
const defaultLink = this.$router ? 'router-link' : 'a'
return this.path ? defaultLink : 'button'
},
validator: value => {
return value.match(/(router-link|a|button)/)
}
},
/**
* Button name for accessibilty
*/
name: {
type: String,
default: null
},
/**
* Primary style
*/
primary: {
type: Boolean,
default: false
},
/**
* Secondary style
*/
secondary: {
type: Boolean,
default: false
},
/**
* Danger style
*/
danger: {
type: Boolean,
default: false
},
/**
* Toggle the hover state
*/
hover: {
type: Boolean,
default: false
},
/**
* Make the buttons background transparent
*/
ghost: {
type: Boolean,
default: false
},
/**
* The name of the buttons icon.
*/
icon: {
type: String,
default: null
},
/**
* Put the icon to the right.
*/
right: {
type: Boolean,
default: false
},
/**
* Should the button spread to the full with of the parent?
*/
fullwidth: {
type: Boolean,
default: false
},
/**
* Show loading state
*/
loading: {
type: Boolean,
default: false
}
},
computed: {
bindings() {
const bindings = {}
if (this.path && this.linkTag === 'router-link') {
bindings.to = this.path
}
if (this.path && this.linkTag === 'a') {
bindings.href = this.path
}
if (this.loading) {
bindings.disabled = true
}
return bindings
},
iconOnly() {
return !this.$slots.default && this.icon
}
},
methods: {
handleClick(event) {
/**
* Click on button.
* Receives two arguments:
* event, route object
*
* @event click
*/
this.$emit('click', event, this.route)
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,269 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-button {
@include reset;
position: relative;
width: auto;
overflow: visible;
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
-webkit-appearance: none;
border: 0;
cursor: pointer;
user-select: none;
font-size: $font-size-base;
line-height: $line-height-base;
font-family: $font-family-text;
font-weight: $font-weight-bold;
letter-spacing: $letter-spacing-large;
display: inline-flex;
vertical-align: middle;
align-items: center;
justify-content: center;
text-decoration: none;
padding: $input-padding-vertical $space-small;
height: $input-height;
border-radius: $border-radius-base;
box-shadow: $box-shadow-small-inset;
// border: $input-border-size solid transparent;
transition: color $duration-short $ease-out,
background-color $duration-short $ease-out;
&:before {
position: absolute;
content: '';
top: $space-xxx-small;
left: $space-xxx-small;
right: $space-xxx-small;
bottom: $space-xxx-small;
border-radius: $border-radius-base;
// box-shadow: $box-shadow-inset;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
&:after {
position: absolute;
content: '';
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
border-radius: $border-radius-base;
border: 1px dotted currentColor;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all $duration-short $ease-out;
}
&:focus {
outline: none;
}
&:active {
&:before {
opacity: 0.6;
}
}
&::-moz-focus-inner {
border: 0;
padding: 0;
}
&:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
// Default colors
color: $text-color-base;
background-color: $background-color-softer;
&:hover,
&.ds-button-hover {
color: $text-color-base;
background-color: $background-color-softer-active;
}
}
.ds-button-primary {
color: $text-color-primary-inverse;
background-color: $background-color-primary;
&:hover,
&.ds-button-hover {
color: $text-color-primary-inverse;
background-color: $background-color-primary-active;
}
&:active {
&:before {
opacity: 1;
}
}
}
.ds-button-secondary {
color: $text-color-secondary-inverse;
background-color: $background-color-secondary;
&:hover,
&.ds-button-hover {
color: $text-color-secondary-inverse;
background-color: $background-color-secondary-active;
}
&:active {
&:before {
opacity: 1;
}
}
}
.ds-button-danger {
color: $text-color-danger-inverse;
background-color: $background-color-danger;
&:hover,
&.ds-button-hover {
color: $text-color-danger-inverse;
background-color: $background-color-danger-active;
}
&:active {
&:before {
opacity: 1;
}
}
}
.ds-button-ghost {
color: $text-color-base;
background-color: transparent;
box-shadow: none;
&:focus {
box-shadow: none;
}
&:hover,
&.ds-hover {
color: $text-color-base;
background-color: $background-color-soft;
}
&:active {
&:before {
opacity: 0.6;
}
}
&.ds-button-primary {
color: $text-color-primary;
}
&.ds-button-secondary {
color: $text-color-secondary;
}
&.ds-button-danger {
color: $text-color-danger;
}
}
.ds-button-size-small {
font-size: $font-size-small;
padding: $input-padding-vertical-small $space-x-small;
height: $input-height-small;
}
.ds-button-size-large {
font-size: $font-size-large;
padding: $input-padding-vertical-large $space-base;
height: $input-height-large;
}
.ds-button-size-x-large {
font-size: $font-size-x-large;
padding: $input-padding-vertical-large $space-base;
height: $input-height-x-large;
}
.ds-button-icon-only {
width: $input-height;
padding: 0;
border-radius: $border-radius-rounded;
&:before,
&:after {
border-radius: $border-radius-rounded;
}
&.ds-button-size-small {
width: $input-height-small;
}
&.ds-button-size-large {
width: $input-height-large;
}
&.ds-button-size-x-large {
width: $input-height-x-large;
}
}
.ds-button-text {
line-height: inherit;
display: inline-block;
white-space: nowrap;
}
.ds-button-fullwidth {
width: 100%;
}
.ds-button-wrap {
transition: opacity 150ms ease-in-out;
display: flex;
align-items: center;
& > * {
margin: 0 $font-space-small;
}
& > *:first-child {
margin-left: 0;
}
& > *:last-child {
margin-right: 0;
}
.ds-button-loading & {
opacity: 0.1;
}
}
.ds-button-right > .ds-button-wrap {
& > *:first-child {
margin-right: 0;
margin-left: $font-space-small;
}
& > *:last-child {
margin-right: 0;
margin-left: 0;
}
}
.ds-button-right .ds-button-wrap {
flex-flow: row-reverse;
}
.ds-button .ds-spinner {
position: absolute;
width: 60% !important;
height: 60% !important;
}

View File

@ -0,0 +1,144 @@
<template>
<nav
class="ds-menu"
:class="[
inverse && 'ds-menu-inverse',
navbar && 'ds-menu-navbar'
]"
>
<ul class="ds-menu-list">
<slot>
<slot
v-for="(route, index) in routes"
:route="route"
:parents="[]"
:name="route.name">
<!-- @slot Scoped slot for providing a custom menu item -->
<slot
:route="route"
name="menuitem">
<ds-menu-item
:key="route.path ? route.path : index"
:route="route" />
</slot>
</slot>
</slot>
</ul>
</nav>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Used in combination with the menu item to help the user navigate.
* @version 1.0.0
*/
export default defineComponent({
emits: ['navigate'],
name: 'DsMenu',
provide() {
return {
$parentMenu: this
}
},
props: {
/**
* The routes to display
*/
routes: {
type: Array,
default() {
return null
}
},
/**
* Set to true, if you use it on dark background
*/
inverse: {
type: Boolean,
default: false
},
/**
* Display menu as a navbar
*/
navbar: {
type: Boolean,
default: false
},
/**
* The default component / tag used for the link of menu items
* @options router-link|a
*/
linkTag: {
type: String,
default() {
return this.$router ? 'router-link' : 'a'
},
validator: value => {
return value.match(/(router-link|a)/)
}
},
/**
* Function that parses the url for each menu item
*/
urlParser: {
type: Function,
default(route, parents) {
if (route.path) {
return route.path
}
const parseName = this.$options.filters.kebabCase
const routeParts = [...parents, route].map(p => parseName(p.name))
return '/' + routeParts.join('/')
}
},
/**
* Function that parses the name for each menu item
*/
nameParser: {
type: Function,
default(route) {
return route.name
}
},
/**
* Function that matches items exactly
*/
matcher: {
type: Function,
default: () => {
return false
}
},
/**
* Function that checks if the url must be matched exactly in order to activate the menu item. By default only '/' must be matched exactly.
*/
isExact: {
type: Function,
default(url) {
return url === '/' || url.path === '/'
}
}
},
computed: {},
methods: {
handleNavigate() {
/**
* Menu navigates to route.
*
* @event navigate
*/
this.$emit('navigate')
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,192 @@
<template>
<li
class="ds-menu-item"
:class="[
`ds-menu-item-level-${level}`,
$parentMenu.inverse && 'ds-menu-item-inverse',
$parentMenu.navbar && 'ds-menu-item-navbar',
showSubmenu && 'ds-menu-item-show-submenu'
]"
@mouseover="handleMouseOver"
@mouseout="handleMouseOut"
@click.capture="handleClick"
v-click-outside="handleClickOutside"
>
<component
v-if="route"
class="ds-menu-item-link"
:class="[
matcher && 'router-link-exact-active'
]"
v-bind="bindings"
:exact="isExact"
:is="linkTag"
ref="link"
>
<slot>
{{ name }}
</slot>
</component>
<ul
class="ds-menu-item-submenu"
v-if="hasSubmenu"
>
<ds-menu-item
v-for="child in route.children"
:key="child.name"
:route="child"
:parents="[...parents, route]"
/>
</ul>
</li>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import ClickOutside from 'vue-click-outside'
/**
* Used in combination with the menu item to help the user navigate.
* @version 1.0.0
* @see DsMenu
*/
export default defineComponent({
emits: ['click'],
name: 'DsMenuItem',
inject: {
$parentMenu: {
default: null
}
},
directives: {
ClickOutside
},
props: {
/**
* The route to display
*/
route: {
type: Object,
default() {
return null
}
},
/**
* The parents of this route
*/
parents: {
type: Array,
default() {
return []
}
},
/**
* The component / tag used for the link of this route
* @options router-link|a
*/
linkTag: {
type: String,
default() {
return this.$parentMenu.linkTag
? this.$parentMenu.linkTag
: 'router-link'
},
validator: value => {
return value.match(/(router-link|a)/)
}
}
},
data() {
return {
showSubmenu: false,
openMenuTimeout: null,
closeMenuTimeout: null
}
},
computed: {
hasSubmenu() {
return this.route.children && this.route.children.length
},
url() {
return this.$parentMenu.urlParser(this.route, this.parents)
},
name() {
return this.$parentMenu.nameParser(this.route, this.parents)
},
isExact() {
return this.$parentMenu.isExact(this.url)
},
matcher() {
return this.$parentMenu.matcher(this.url, this.route)
},
level() {
return this.parents.length
},
bindings() {
const bindings = {}
if (this.linkTag === 'router-link') {
bindings.to = this.url
}
if (this.linkTag === 'a') {
bindings.href = this.url
}
return bindings
}
},
methods: {
handleMouseOver() {
if (this.closeMenuTimeout) {
clearTimeout(this.closeMenuTimeout)
}
this.openMenuTimeout = setTimeout(() => {
if (this.$parentMenu.navbar && this.hasSubmenu && !this.showSubmenu) {
this.showSubmenu = true
}
}, 150)
},
handleMouseOut() {
if (this.openMenuTimeout) {
clearTimeout(this.openMenuTimeout)
}
this.closeMenuTimeout = setTimeout(() => {
if (this.$parentMenu.navbar && this.hasSubmenu && this.showSubmenu) {
this.showSubmenu = false
}
}, 150)
},
handleClick(event) {
const clickedLink = event.target === this.$refs.link.$el
if (
clickedLink &&
this.$parentMenu.navbar &&
this.hasSubmenu &&
!this.showSubmenu
) {
this.showSubmenu = true
event.preventDefault()
event.stopPropagation()
return
}
/**
* Handles click on menu item.
* Receives two arguments:
* event, route object
*
* @event click
*/
this.$emit('click', event, this.route)
this.$parentMenu.handleNavigate()
},
handleClickOutside() {
this.showSubmenu = false
}
},
});
</script>

View File

@ -0,0 +1,172 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-menu {
@include reset;
}
.ds-menu-inverse {
background-color: $background-color-inverse-soft;
}
.ds-menu-navbar {
height: 100%;
}
ul.ds-menu-list {
@include reset;
list-style: none;
padding-left: 0;
.ds-menu-navbar & {
display: flex;
height: 100%;
}
}
.ds-menu-item {
}
.ds-menu-item-navbar {
position: relative;
&.ds-menu-item-level-0 {
margin-right: $space-x-small;
height: 100%;
&:last-of-type {
margin-right: 0;
}
}
}
.ds-menu-item-link {
@include reset;
display: block;
color: $text-color-base;
text-decoration: none;
padding: $space-x-small $space-small;
transition: color $duration-short $ease-out;
border-left: 2px solid transparent;
&.router-link-active {
color: $text-color-link-active;
}
&:hover {
color: $text-color-link-active;
}
&.router-link-exact-active {
color: $text-color-link;
background-color: $background-color-soft;
border-left: 2px solid $color-primary;
}
.ds-menu-item-inverse & {
color: $text-color-softer;
&.router-link-active {
color: $text-color-link-active;
}
&:hover {
color: $text-color-link-active;
}
&.router-link-exact-active {
background-color: $background-color-inverse;
}
}
.ds-menu-item-inverse.ds-menu-item-show-submenu > & {
color: $text-color-link-active;
}
.ds-menu-item-level-1 & {
font-size: $font-size-small;
padding-left: $space-x-small * 3;
}
.ds-menu-item-level-2 & {
font-size: $font-size-small;
padding-left: $space-x-small * 4;
}
.ds-menu-item-navbar & {
font-size: $font-size-base;
padding: $space-small $space-small;
}
.ds-menu-item-navbar.ds-menu-item-level-0 > & {
position: relative;
height: 100%;
display: inline-flex;
align-items: center;
font-weight: $font-weight-bold;
&:before {
position: absolute;
content: '';
left: 0;
right: 0;
bottom: 0;
height: $border-size-large;
background: $text-color-link-active;
opacity: 0;
transition: opacity $duration-short $ease-out;
}
&,
&:hover,
&.router-link-exact-active {
background-color: transparent;
}
&:hover,
&.router-link-active {
&:before {
opacity: 1;
}
}
}
.ds-menu-item-navbar.ds-menu-item-show-submenu.ds-menu-item-level-0 > & {
color: $text-color-link-active;
&:before {
opacity: 1;
}
}
}
ul.ds-menu-item-submenu {
@include reset;
list-style: none;
padding-left: 0;
.ds-menu-item-navbar & {
position: absolute;
left: 0;
top: 100%;
min-width: 150px;
z-index: $z-index-page-submenu;
background-color: $background-color-base;
box-shadow: $box-shadow-base;
opacity: 0;
visibility: hidden;
transform: translateY($space-x-small) scaleY(0.5);
transform-origin: 50% 0%;
transition: all $duration-short $ease-in;
}
.ds-menu-item-navbar.ds-menu-item-inverse & {
background-color: $background-color-inverse-soft;
}
.ds-menu-item-navbar.ds-menu-item-show-submenu > & {
opacity: 1;
visibility: visible;
transform: translateY($space-x-small) scaleX(1);
transition: all $duration-short $ease-out;
}
}

View File

@ -0,0 +1,97 @@
<template>
<component
:is="tag"
class="ds-chip"
:class="[
`ds-chip-size-${size}`,
`ds-chip-${color}`,
removable && 'ds-chip-removable',
round && 'ds-chip-round'
]"
>
<slot />
<button
v-if="removable"
@click="remove"
class="ds-chip-close"
tabindex="-1"
>
<ds-icon name="close" />
</button>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Chips are used to represent small blocks of information.
* Their most common usage is for displaying contacts or tags.
* @version 1.0.0
*/
export default defineComponent({
emits: ['remove'],
name: 'DsChip',
props: {
/**
* The background color used for the chip.
* @options medium|inverse|primary|success|warning|danger
*/
color: {
type: String,
default: 'medium',
validator: value => {
return value.match(/(medium|inverse|primary|success|warning|danger)/)
}
},
/**
* The size used for the text.
* @options base|large|small
*/
size: {
type: String,
default: 'base',
validator: value => {
return value.match(/(base|large|small)/)
}
},
/**
* Whether the chip should be removeable
*/
removable: {
type: Boolean,
default: false
},
/**
* Whether the chip should be rounded
*/
round: {
type: Boolean,
default: true
},
/**
* The html element name used for the text.
*/
tag: {
type: String,
default: 'span'
}
},
methods: {
remove() {
/**
* Fires after user clicked the remove button.
*
* @event remove
*/
this.$emit('remove')
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,94 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-chip {
@include reset;
@include stack-space($space-xx-small);
@include inline-space($font-space-xx-small);
position: relative;
display: inline-block;
font-family: $font-family-text;
line-height: $line-height-base;
padding: $font-space-xx-small $font-space-large;
padding-bottom: $font-space-xxx-small;
border-radius: $border-radius-base;
font-weight: $font-weight-bold;
color: $text-color-base;
background-color: $background-color-softest;
&.ds-chip-removable {
padding-right: $space-x-small + $space-small;
}
}
.ds-chip-inverse {
color: $text-color-inverse;
background-color: $background-color-inverse-softer;
}
.ds-chip-primary {
color: $text-color-primary-inverse;
background-color: $background-color-primary;
}
.ds-chip-success {
color: $text-color-success-inverse;
background-color: $background-color-success;
}
.ds-chip-warning {
color: $text-color-warning-inverse;
background-color: $background-color-warning;
}
.ds-chip-danger {
color: $text-color-danger-inverse;
background-color: $background-color-danger;
}
.ds-chip-round {
border-radius: $border-radius-rounded;
}
.ds-chip-size-small {
font-size: $font-size-xx-small;
padding: $font-space-xxx-small ($font-space-large + $font-space-xxx-small);
}
.ds-chip-size-base {
font-size: $font-size-small;
padding-left: $font-space-large + $font-space-xx-small;
padding-right: $font-space-large + $font-space-xx-small;
padding-top: $font-space-xxx-small;
}
.ds-chip-size-large {
font-size: $font-size-base;
padding-left: $font-space-x-large;
padding-right: $font-space-x-large;
padding-top: $font-space-xx-small;
padding-bottom: $font-space-xxx-small;
}
.ds-chip-close {
@include reset-button;
position: absolute;
right: $font-space-base;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: $font-size-x-small;
width: $space-small;
height: $space-small;
border-radius: $border-radius-circle;
//background-color: $background-color-base;
opacity: $opacity-soft;
cursor: pointer;
transition: all $duration-short $ease-out-sharp;
&:hover {
opacity: 1;
}
}

View File

@ -0,0 +1,37 @@
<template>
<component
class="ds-code"
:is="inline ? 'code' : 'pre'"
:class="[
inline && `ds-code-inline`
]"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* The code component is used for displaying lines of code.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsCode',
props: {
/**
* Display the code inline.
*/
inline: {
type: Boolean,
default: false
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,31 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-code {
@include reset;
@include stack-space($font-space-x-large);
font-family: $font-family-code;
line-height: $line-height-base;
color: $text-color-inverse;
background: $background-color-inverse-softer;
padding: $space-small;
border-radius: $border-radius-base;
}
.ds-code-inline {
display: inline-block;
padding: $space-xxx-small $space-x-small;
margin-bottom: 0;
}
.ds-code-size-small {
font-size: $font-size-small;
}
.ds-code-size-base {
font-size: $font-size-base;
}
.ds-code-size-large {
font-size: $font-size-large;
}

View File

@ -0,0 +1,91 @@
<template>
<component
:is="tag"
class="ds-heading"
:class="[
`ds-heading-${size || tag}`,
align && `ds-heading-align-${align}`,
primary && `ds-heading-primary`,
soft && `ds-heading-soft`,
noMargin && `ds-heading-no-margin`
]"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Headings are used as the titles of each major
* section of a page in the interface.
*
* @version 1.0.0
*/
export default defineComponent({
name: 'DsHeading',
props: {
/**
* The heading type used for the heading.
* @options h1|h2|h3|h4|h5|h6
*/
tag: {
type: String,
default: 'h1',
validator: value => {
return value.match(/(h1|h2|h3|h4|h5|h6)/)
}
},
/**
* The size used for the heading.
* @options h1|h2|h3|h4|h5|h6
*/
size: {
type: String,
default: null,
validator: value => {
return value.match(/(h1|h2|h3|h4|h5|h6)/)
}
},
/**
* Primary style
*/
primary: {
type: Boolean,
default: false
},
/**
* Muted style
*/
soft: {
type: Boolean,
default: false
},
/**
* Remove Margin
* `true, false`
*/
noMargin: {
type: Boolean,
default: false
},
/**
* Text Align
* `left, center, right`
*/
align: {
type: String,
default: null,
validator: value => {
return value.match(/(left|center|right)/)
}
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Heading.vue matches snapshot 1`] = `
<h1
class="ds-heading ds-heading-h1"
>
Winter is coming
</h1>
`;

View File

@ -0,0 +1,27 @@
import { describe, expect, beforeEach, it } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import Comp from './Heading.vue'
describe('Heading.vue', () => {
let wrapper
beforeEach(() => {
wrapper = shallowMount(Comp, {
slots: {
default: 'Winter is coming'
}
})
})
it('defaults to h1', () => {
expect(wrapper.props().tag).toEqual('h1')
})
it('displays title', () => {
expect(wrapper.text()).toEqual('Winter is coming')
})
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot()
})
})

View File

@ -0,0 +1,60 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-heading {
@include reset;
@include stack-space($font-space-large, $font-space-xxxx-large);
font-family: $font-family-heading;
line-height: $line-height-small;
letter-spacing: $letter-spacing-small;
font-weight: $font-weight-bold;
}
.ds-heading-primary {
color: $text-color-primary;
}
.ds-heading-soft {
color: $text-color-softer;
}
.ds-heading-h1 {
font-size: $font-size-xx-large;
@media #{$media-query-large} {
font-size: $font-size-xxx-large;
}
}
.ds-heading-h2 {
font-size: $font-size-xx-large;
}
.ds-heading-h3 {
font-size: $font-size-x-large;
}
.ds-heading-h4 {
font-size: $font-size-large;
}
.ds-heading-h5 {
font-size: $font-size-base;
}
.ds-heading-h6 {
font-size: $font-size-small;
}
.ds-heading-no-margin {
margin: 0;
}
.ds-heading-align-left {
text-align: left
}
.ds-heading-align-center {
text-align: center
}
.ds-heading-align-right {
text-align: right
}

View File

@ -0,0 +1,77 @@
<template>
<component
:is="tag"
:aria-label="ariaLabel"
:class="[size && `ds-icon-size-${size}`]"
class="ds-icon"
>
<component
v-if="svgComponent"
:is="svgComponent"
class="ds-icon-svg"
/>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import icons from '@@/icons'
/**
* Icons are used to add meaning and improve accessibility.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsIcon',
props: {
/**
* The name of the icon.
*/
name: {
type: String,
required: true
},
/**
* Descriptive text to be read to screenreaders.
*/
ariaLabel: {
type: String,
default: 'icon'
},
/**
* The html element name used for the icon.
*/
tag: {
type: String,
default: 'span'
},
/**
* Which size should the icon have?
* `xx-small, x-small, small, base, large, x-large, xx-large, xxx-large`
*/
size: {
type: String,
default: null,
validator: (value: string) => {
return value.match(
/(xx-small|x-small|small|base|large|x-large|xx-large|xxx-large)/
)
}
}
},
computed: {
svgComponent() {
const icon = icons[this.name]
if (!icon) {
return false
}
return icon.render ? icon : icon.default
}
},
});
</script>
<style lang="scss" src="./style.scss"></style>

View File

@ -0,0 +1,48 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-icon {
@include reset;
display: inline-flex;
align-items: center;
vertical-align: middle;
height: 1em;
}
.ds-icon-svg {
line-height: 1;
height: 1.2em;
// Use this if the icons are build with strokes
// stroke: currentColor;
// stroke-width: 2.5px;
// stroke-linejoin: miter;
// stroke-linecap: miter;
// overflow: visible;
// Use this if the icons are build with solids
fill: currentColor
}
.ds-icon-size-xx-small {
font-size: $font-size-xx-small
}
.ds-icon-size-x-small {
font-size: $font-size-x-small
}
.ds-icon-size-small {
font-size: $font-size-small
}
.ds-icon-size-base {
font-size: $font-size-base
}
.ds-icon-size-large {
font-size: $font-size-large
}
.ds-icon-size-x-large {
font-size: $font-size-x-large
}
.ds-icon-size-xx-large {
font-size: $font-size-xx-large
}
.ds-icon-size-xxx-large {
font-size: $font-size-xxx-large
}

View File

@ -0,0 +1,69 @@
<template>
<component
:is="tag"
class="ds-tag"
:class="[
`ds-tag-size-${size}`,
`ds-tag-${color}`,
round && 'ds-tag-round'
]"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Tags are used for styling and highlighting small pieces of information.
* Most of the time they are used for keywords or numbers.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsTag',
props: {
/**
* The background color used for the tag.
* @options medium|inverse|primary|success|warning|danger
*/
color: {
type: String,
default: 'medium',
validator: value => {
return value.match(/(medium|inverse|primary|success|warning|danger)/)
}
},
/**
* The size used for the text.
* @options base|large|small
*/
size: {
type: String,
default: 'base',
validator: value => {
return value.match(/(base|large|small)/)
}
},
/**
* Whether the tag should be round
*/
round: {
type: Boolean,
default: false
},
/**
* The html element name used for the text.
*/
tag: {
type: String,
default: 'span'
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,66 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-tag {
@include reset;
@include stack-space($space-xx-small);
@include inline-space($font-space-xx-small);
display: inline-block;
font-family: $font-family-text;
line-height: $line-height-base;
padding: $font-space-xx-small $font-space-x-large;
padding-top: $font-space-x-small;
border-radius: $border-radius-base;
font-weight: $font-weight-bold;
letter-spacing: $letter-spacing-large;
// text-transform: uppercase;
color: $text-color-base;
background-color: $background-color-softest;
}
.ds-tag-inverse {
color: $text-color-inverse;
background-color: $background-color-inverse-softer;
}
.ds-tag-primary {
color: $text-color-primary-inverse;
background-color: $background-color-primary;
}
.ds-tag-success {
color: $text-color-success-inverse;
background-color: $background-color-success;
}
.ds-tag-warning {
color: $text-color-warning-inverse;
background-color: $background-color-warning;
}
.ds-tag-danger {
color: $text-color-danger-inverse;
background-color: $background-color-danger;
}
.ds-tag-round {
border-radius: $border-radius-rounded;
padding-left: $font-space-large + $font-space-xxx-small;
padding-right: $font-space-large + $font-space-xxx-small;
}
.ds-tag-size-base {
font-size: $font-size-x-small;
padding-top: $font-space-x-small;
padding-bottom: $font-space-xx-small;
}
.ds-tag-size-small {
font-size: $font-size-xx-small;
padding: $font-space-xxx-small $font-space-large;
padding-top: $font-space-x-small;
}
.ds-tag-size-large {
font-size: $font-size-small;
}

View File

@ -0,0 +1,117 @@
<template>
<component
:is="tag"
class="ds-text"
:class="[
size && `ds-text-size-${size}`,
color && `ds-text-${color}`,
bold && `ds-text-bold`,
inline && `ds-text-inline`,
align && `ds-text-${align}`,
uppercase && `ds-text-uppercase`
]"
>
<slot />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* Text is used for styling and grouping paragraphs or words.
* Defaults to a `p` tag. If nested inside of another text
* component it defaults to a `span` tag.
* @version 1.0.0
*/
export default defineComponent({
name: 'DsText',
provide() {
return {
$parentText: this
}
},
inject: {
$parentText: {
default: null
}
},
props: {
/**
* The color used for the text.
* @options default|soft|softer|primary|inverse|success|warning|danger
*/
color: {
type: String,
default: null,
validator: (value: string) => {
return value.match(
/(default|soft|softer|primary|inverse|success|warning|danger)/
)
}
},
/**
* Whether the text is bold.
*/
bold: {
type: Boolean,
default: null
},
/**
* Whether the text is inline.
* @default false
*/
inline: {
type: Boolean,
default() {
return !!this.$parentText
}
},
/**
* The size used for the text.
* @options small|base|large|x-large|xx-large|xxx-large
*/
size: {
type: String,
default: null,
validator: value => {
return value.match(/(small|base|large|x-large|xx-large|xxx-large)/)
}
},
/**
* The html tag used for the text.
*/
tag: {
type: String,
default(): string {
return this.inline ? 'span' : 'p'
}
},
/**
* Align Text
* `left, center, right
*/
align: {
type: String,
default: null,
validator: value => {
return value.match(/(left|center|right)/)
}
},
/**
* Text in Uppdercase
*/
uppercase: {
type: Boolean,
default: false
}
},
});
</script>
<style lang="scss" src="./style.scss">
</style>

View File

@ -0,0 +1,82 @@
@use "@@/styles/shared" as *;
@use "@@/styles/tokens/tokens" as *;
.ds-text {
@include reset;
@include stack-space($font-space-x-large);
font-family: $font-family-text;
line-height: $line-height-base;
display: block;
}
.ds-text-bold {
font-weight: $font-weight-bold;
}
.ds-text-inline {
display: inline;
}
.ds-text-left {
text-align: left;
}
.ds-text-center {
text-align: center;
}
.ds-text-right {
text-align: right;
}
.ds-text-uppercase {
text-transform: uppercase;
}
.ds-text-size-small {
font-size: $font-size-small;
}
.ds-text-size-base {
font-size: $font-size-base;
}
.ds-text-size-large {
font-size: $font-size-large;
}
.ds-text-size-x-large {
font-size: $font-size-x-large;
}
.ds-text-size-xx-large {
font-size: $font-size-xx-large;
}
.ds-text-size-xxx-large {
font-size: $font-size-xxx-large;
}
.ds-text-soft {
color: $text-color-soft;
}
.ds-text-softer {
color: $text-color-softer;
}
.ds-text-primary {
color: $text-color-primary;
}
.ds-text-success {
color: $text-color-success;
}
.ds-text-danger {
color: $text-color-danger;
}
.ds-text-warning {
color: $text-color-warning;
}

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>adjust</title>
<path d="M16 4c6.616 0 12 5.384 12 12s-5.384 12-12 12-12-5.384-12-12 5.384-12 12-12zM16 6c-5.535 0-10 4.465-10 10s4.465 10 10 10v-20z"></path>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>adn</title>
<path d="M16 4c6.616 0 12 5.384 12 12s-5.384 12-12 12-12-5.384-12-12 5.384-12 12-12zM16 6c-5.535 0-10 4.465-10 10s4.465 10 10 10 10-4.465 10-10-4.465-10-10-10zM16 9.938l6.625 9.906h-1.594l-1.563-2.313h-6.938l-1.531 2.313h-1.594zM16 12.344l-2.844 4.25h5.688z"></path>
</svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>align-center</title>
<path d="M3 7h26v2h-26v-2zM7 11h18v2h-18v-2zM3 15h26v2h-26v-2zM7 19h18v2h-18v-2zM3 23h26v2h-26v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 274 B

Some files were not shown because too many files have changed in this diff Show More