webcake-ui-kit 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-ui-kit",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "UI Kit for Vue 2 && 3 - Pure Options API",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -18,6 +18,8 @@
18
18
  "./WkAccordion": "./src/components/accordion/Accordion.vue",
19
19
  "./WkAccordionItem": "./src/components/accordion-item/AccordionItem.vue",
20
20
  "./WkAlertDialog": "./src/components/alert-dialog/AlertDialog.vue",
21
+ "./WkAvatar": "./src/components/avatar/Avatar.vue",
22
+ "./WkAvatarStack": "./src/components/avatar-stack/AvatarStack.vue",
21
23
  "./WkBadge": "./src/components/badge/Badge.vue",
22
24
  "./WkBreadcrumb": "./src/components/breadcrumb/Breadcrumb.vue",
23
25
  "./WkButton": "./src/components/button/Button.vue",
@@ -26,6 +28,9 @@
26
28
  "./WkCheckboxGroup": "./src/components/checkbox-group/CheckboxGroup.vue",
27
29
  "./WkDialog": "./src/components/dialog/Dialog.vue",
28
30
  "./WkDivider": "./src/components/divider/Divider.vue",
31
+ "./WkEmpty": "./src/components/empty/Empty.vue",
32
+ "./WkEmptyIcon": "./src/components/empty-icon/EmptyIcon.vue",
33
+ "./WkField": "./src/components/field/Field.vue",
29
34
  "./WkInput": "./src/components/input/Input.vue",
30
35
  "./WkPagination": "./src/components/pagination/Pagination.vue",
31
36
  "./WkRadio": "./src/components/radio/Radio.vue",
@@ -44,6 +49,7 @@
44
49
  "./WkTag": "./src/components/tag/Tag.vue",
45
50
  "./WkToggle": "./src/components/toggle/Toggle.vue",
46
51
  "./WkToggleGroup": "./src/components/toggle-group/ToggleGroup.vue",
52
+ "./WkTooltip": "./src/components/tooltip/Tooltip.vue",
47
53
  "./WkTypography": "./src/components/typography/Typography.vue",
48
54
  "./components/*": "./src/components/*",
49
55
  "./icons": "./src/icons/index.js",
@@ -0,0 +1,54 @@
1
+ <template>
2
+ <span :class="['ui-avatar', `ui-avatar--${size}`, `ui-avatar--${roundness}`]">
3
+ <img v-if="showImage" class="ui-avatar__image" :src="src" :alt="alt" @error="onImageError" />
4
+ <span v-else class="ui-avatar__fallback">
5
+ <slot>{{ name }}</slot>
6
+ </span>
7
+ <span v-if="online" class="ui-avatar__indicator" aria-hidden="true"></span>
8
+ </span>
9
+ </template>
10
+
11
+ <script>
12
+ export default {
13
+ name: 'Avatar',
14
+ props: {
15
+ size: {
16
+ type: String,
17
+ default: 'regular',
18
+ validator: v => ['regular', 'small', 'tiny', 'extra-tiny'].includes(v)
19
+ },
20
+ roundness: {
21
+ type: String,
22
+ default: 'round',
23
+ validator: v => ['round', 'roundrect'].includes(v)
24
+ },
25
+ src: { type: String, default: '' },
26
+ alt: { type: String, default: '' },
27
+ name: { type: String, default: '' },
28
+ online: { type: Boolean, default: false }
29
+ },
30
+ emits: [],
31
+ data() {
32
+ return {
33
+ imageError: false
34
+ }
35
+ },
36
+ computed: {
37
+ showImage() {
38
+ return !!this.src && !this.imageError
39
+ }
40
+ },
41
+ watch: {
42
+ src() {
43
+ this.imageError = false
44
+ }
45
+ },
46
+ methods: {
47
+ onImageError() {
48
+ this.imageError = true
49
+ }
50
+ }
51
+ }
52
+ </script>
53
+
54
+ <style src="./avatar.css" scoped></style>
@@ -0,0 +1,91 @@
1
+ .ui-avatar {
2
+ position: relative;
3
+ display: inline-flex;
4
+ align-items: center;
5
+ justify-content: center;
6
+ flex-shrink: 0;
7
+ background: var(--accent-bg);
8
+ color: var(--primary-fg);
9
+ font-family: var(--font-family-body);
10
+ font-weight: var(--paragraph-bold-font-weight);
11
+ letter-spacing: var(--paragraph-small-letter-spacing);
12
+ vertical-align: middle;
13
+ overflow: visible;
14
+ }
15
+
16
+ /* Sizes — match Figma frame widths exactly */
17
+ .ui-avatar--regular {
18
+ width: 40px;
19
+ height: 40px;
20
+ font-size: var(--paragraph-small-font-size);
21
+ line-height: var(--paragraph-small-line-height);
22
+ }
23
+ .ui-avatar--small {
24
+ width: 32px;
25
+ height: 32px;
26
+ font-size: var(--paragraph-mini-font-size);
27
+ line-height: var(--paragraph-mini-line-height);
28
+ }
29
+ .ui-avatar--tiny {
30
+ width: 24px;
31
+ height: 24px;
32
+ font-size: 10px;
33
+ line-height: 16px;
34
+ }
35
+ .ui-avatar--extra-tiny {
36
+ width: 20px;
37
+ height: 20px;
38
+ font-size: 9px;
39
+ line-height: 12px;
40
+ }
41
+
42
+ /* Roundness */
43
+ .ui-avatar--round {
44
+ border-radius: var(--rounded-full);
45
+ }
46
+ .ui-avatar--roundrect {
47
+ border-radius: var(--rounded-lg);
48
+ }
49
+
50
+ /* Image */
51
+ .ui-avatar__image {
52
+ display: block;
53
+ width: 100%;
54
+ height: 100%;
55
+ object-fit: cover;
56
+ border-radius: inherit;
57
+ }
58
+
59
+ /* Fallback (initials / custom slot) */
60
+ .ui-avatar__fallback {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ width: 100%;
65
+ height: 100%;
66
+ text-align: center;
67
+ user-select: none;
68
+ }
69
+
70
+ /* Online indicator — auto-sized per avatar size, 2px white ring punches out of avatar */
71
+ .ui-avatar__indicator {
72
+ position: absolute;
73
+ right: 0;
74
+ bottom: 0;
75
+ border-radius: var(--rounded-full);
76
+ background: var(--positive-500);
77
+ box-shadow: 0 0 0 2px var(--primary-bg);
78
+ }
79
+ .ui-avatar--regular .ui-avatar__indicator {
80
+ width: 10px;
81
+ height: 10px;
82
+ }
83
+ .ui-avatar--small .ui-avatar__indicator {
84
+ width: 8px;
85
+ height: 8px;
86
+ }
87
+ .ui-avatar--tiny .ui-avatar__indicator,
88
+ .ui-avatar--extra-tiny .ui-avatar__indicator {
89
+ width: 6px;
90
+ height: 6px;
91
+ }
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <span :class="['ui-avatar-stack', `ui-avatar-stack--${size}`]">
3
+ <Avatar
4
+ v-for="(item, idx) in visibleItems"
5
+ :key="`avatar-${idx}`"
6
+ :size="size"
7
+ :src="item.src || ''"
8
+ :alt="item.alt || ''"
9
+ :name="item.name || ''"
10
+ :class="itemClasses"
11
+ />
12
+ <Avatar v-if="overflowCount > 0" :size="size" :class="[...itemClasses, 'ui-avatar-stack__overflow']">
13
+ <slot name="overflow" v-bind="{ count: overflowCount }">{{ formattedOverflow }}</slot>
14
+ </Avatar>
15
+ </span>
16
+ </template>
17
+
18
+ <script>
19
+ import Avatar from '../avatar/Avatar.vue'
20
+
21
+ export default {
22
+ name: 'AvatarStack',
23
+ components: { Avatar },
24
+ props: {
25
+ size: {
26
+ type: String,
27
+ default: 'regular',
28
+ validator: v => ['regular', 'small'].includes(v)
29
+ },
30
+ items: {
31
+ type: Array,
32
+ default: () => []
33
+ },
34
+ max: {
35
+ type: Number,
36
+ default: 0
37
+ },
38
+ overflowLabel: {
39
+ type: Function,
40
+ default: null
41
+ },
42
+ animation: {
43
+ type: String,
44
+ default: 'none',
45
+ validator: v => ['none', 'pulse', 'bounce', 'ring'].includes(v)
46
+ }
47
+ },
48
+ emits: [],
49
+ computed: {
50
+ visibleItems() {
51
+ if (this.max > 0 && this.items.length > this.max) {
52
+ return this.items.slice(0, this.max)
53
+ }
54
+ return this.items
55
+ },
56
+ overflowCount() {
57
+ if (this.max > 0 && this.items.length > this.max) {
58
+ return this.items.length - this.max
59
+ }
60
+ return 0
61
+ },
62
+ formattedOverflow() {
63
+ if (typeof this.overflowLabel === 'function') {
64
+ return this.overflowLabel(this.overflowCount)
65
+ }
66
+ return '+' + this.overflowCount
67
+ },
68
+ itemClasses() {
69
+ const classes = ['ui-avatar-stack__item']
70
+ if (this.animation !== 'none') {
71
+ classes.push(`ui-avatar-stack__item--animation-${this.animation}`)
72
+ }
73
+ return classes
74
+ }
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <style src="./avatar-stack.css" scoped></style>
@@ -0,0 +1,110 @@
1
+ .ui-avatar-stack {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ vertical-align: middle;
5
+ }
6
+
7
+ /* Each child avatar overlaps the previous one and shows a white ring to
8
+ "punch out" of its neighbours. Box-shadow follows border-radius and does
9
+ not take up layout space, so the avatar dimensions stay intact. */
10
+ .ui-avatar-stack__item {
11
+ position: relative;
12
+ margin-left: -8px;
13
+ box-shadow: 0 0 0 2px var(--primary-bg);
14
+ }
15
+ .ui-avatar-stack__item:first-child {
16
+ margin-left: 0;
17
+ }
18
+
19
+ /* Stacking order: earlier siblings sit above later ones so the leftmost
20
+ avatar's ring is visible over its neighbour. */
21
+ .ui-avatar-stack__item {
22
+ z-index: 1;
23
+ }
24
+ .ui-avatar-stack__item:nth-child(1) {
25
+ z-index: 5;
26
+ }
27
+ .ui-avatar-stack__item:nth-child(2) {
28
+ z-index: 4;
29
+ }
30
+ .ui-avatar-stack__item:nth-child(3) {
31
+ z-index: 3;
32
+ }
33
+ .ui-avatar-stack__item:nth-child(4) {
34
+ z-index: 2;
35
+ }
36
+
37
+ /* Overflow chip — same shape as siblings, slightly darker background. */
38
+ .ui-avatar-stack__overflow {
39
+ background: var(--secondary-bg);
40
+ color: var(--secondary-fg);
41
+ }
42
+
43
+ /* ============================ Animations ============================
44
+ Three loop animations the consumer opts into via the `animation` prop
45
+ on AvatarStack. Each animation is applied to every visible avatar
46
+ child (including the "+N" overflow chip). All honour
47
+ `prefers-reduced-motion: reduce`. */
48
+
49
+ @keyframes ui-avatar-stack-pulse {
50
+ 0%,
51
+ 100% {
52
+ transform: scale(1);
53
+ opacity: 1;
54
+ }
55
+ 50% {
56
+ transform: scale(0.94);
57
+ opacity: 0.78;
58
+ }
59
+ }
60
+
61
+ @keyframes ui-avatar-stack-bounce {
62
+ 0%,
63
+ 100% {
64
+ transform: translateY(0);
65
+ }
66
+ 50% {
67
+ transform: translateY(-4px);
68
+ }
69
+ }
70
+
71
+ @keyframes ui-avatar-stack-ring {
72
+ 0% {
73
+ transform: scale(1);
74
+ opacity: 0.8;
75
+ }
76
+ 100% {
77
+ transform: scale(1.35);
78
+ opacity: 0;
79
+ }
80
+ }
81
+
82
+ .ui-avatar-stack__item--animation-pulse {
83
+ animation: ui-avatar-stack-pulse 1.6s ease-in-out infinite;
84
+ }
85
+
86
+ .ui-avatar-stack__item--animation-bounce {
87
+ animation: ui-avatar-stack-bounce 1.2s ease-in-out infinite;
88
+ }
89
+
90
+ .ui-avatar-stack__item--animation-ring::after {
91
+ content: '';
92
+ position: absolute;
93
+ inset: -3px;
94
+ border-radius: inherit;
95
+ border: 2px solid var(--positive-500);
96
+ pointer-events: none;
97
+ animation: ui-avatar-stack-ring 1.6s ease-out infinite;
98
+ }
99
+
100
+ @media (prefers-reduced-motion: reduce) {
101
+ .ui-avatar-stack__item--animation-pulse,
102
+ .ui-avatar-stack__item--animation-bounce {
103
+ animation: none;
104
+ }
105
+ .ui-avatar-stack__item--animation-ring::after {
106
+ animation: none;
107
+ opacity: 0.6;
108
+ transform: scale(1.05);
109
+ }
110
+ }
@@ -43,7 +43,7 @@
43
43
  letter-spacing: var(--paragraph-mini-letter-spacing);
44
44
  }
45
45
  .ui-btn--xs .ui-btn__icon,
46
- .ui-btn--xs :deep(svg) {
46
+ .ui-btn--xs ::v-deep svg {
47
47
  width: 16px;
48
48
  height: 16px;
49
49
  }
@@ -57,7 +57,7 @@
57
57
  letter-spacing: var(--paragraph-small-letter-spacing);
58
58
  }
59
59
  .ui-btn--sm .ui-btn__icon,
60
- .ui-btn--sm :deep(svg) {
60
+ .ui-btn--sm ::v-deep svg {
61
61
  width: 20px;
62
62
  height: 20px;
63
63
  }
@@ -71,7 +71,7 @@
71
71
  letter-spacing: var(--paragraph-small-letter-spacing);
72
72
  }
73
73
  .ui-btn--md .ui-btn__icon,
74
- .ui-btn--md :deep(svg) {
74
+ .ui-btn--md ::v-deep svg {
75
75
  width: 20px;
76
76
  height: 20px;
77
77
  }
@@ -85,7 +85,7 @@
85
85
  letter-spacing: var(--paragraph-small-letter-spacing);
86
86
  }
87
87
  .ui-btn--lg .ui-btn__icon,
88
- .ui-btn--lg :deep(svg) {
88
+ .ui-btn--lg ::v-deep svg {
89
89
  width: 20px;
90
90
  height: 20px;
91
91
  }
@@ -99,7 +99,7 @@
99
99
  letter-spacing: var(--paragraph-regular-letter-spacing);
100
100
  }
101
101
  .ui-btn--xl .ui-btn__icon,
102
- .ui-btn--xl :deep(svg) {
102
+ .ui-btn--xl ::v-deep svg {
103
103
  width: 20px;
104
104
  height: 20px;
105
105
  }
@@ -24,7 +24,6 @@
24
24
  .ui-divider--vertical {
25
25
  display: inline-block;
26
26
  width: 1px;
27
- align-self: stretch;
28
27
  }
29
28
 
30
29
  .ui-divider--vertical.ui-divider--spacing-regular {
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <div :class="['ui-empty', `ui-empty--${variant}`]">
3
+ <div class="ui-empty__content">
4
+ <div v-if="hasMedia" class="ui-empty__media">
5
+ <slot name="media"></slot>
6
+ </div>
7
+ <div v-if="hasTextBlock" class="ui-empty__text">
8
+ <p v-if="hasTitle" class="ui-empty__title">
9
+ <slot name="title">{{ title }}</slot>
10
+ </p>
11
+ <p v-if="hasDescription" class="ui-empty__description">
12
+ <slot name="description">{{ description }}</slot>
13
+ </p>
14
+ </div>
15
+ <div v-if="hasFooter" class="ui-empty__footer">
16
+ <slot></slot>
17
+ </div>
18
+ </div>
19
+ </div>
20
+ </template>
21
+
22
+ <script>
23
+ export default {
24
+ name: 'Empty',
25
+ props: {
26
+ variant: {
27
+ type: String,
28
+ default: 'default',
29
+ validator: v => ['default', 'outline', 'background', 'outline-dashed'].includes(v)
30
+ },
31
+ title: { type: String, default: '' },
32
+ description: { type: String, default: '' }
33
+ },
34
+ emits: [],
35
+ computed: {
36
+ hasMedia() {
37
+ return !!((this.$scopedSlots && this.$scopedSlots.media) || this.$slots.media)
38
+ },
39
+ hasTitle() {
40
+ return !!this.title || !!((this.$scopedSlots && this.$scopedSlots.title) || this.$slots.title)
41
+ },
42
+ hasDescription() {
43
+ return !!this.description || !!((this.$scopedSlots && this.$scopedSlots.description) || this.$slots.description)
44
+ },
45
+ hasTextBlock() {
46
+ return this.hasTitle || this.hasDescription
47
+ },
48
+ hasFooter() {
49
+ return !!((this.$scopedSlots && this.$scopedSlots.default) || this.$slots.default)
50
+ }
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <style src="./empty.css" scoped></style>
@@ -0,0 +1,80 @@
1
+ .ui-empty {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: stretch;
5
+ padding: var(--spacing-2xl);
6
+ border-radius: var(--rounded-2xl);
7
+ width: 100%;
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ /* Variants */
12
+ .ui-empty--default {
13
+ background: transparent;
14
+ border: 0;
15
+ }
16
+ .ui-empty--outline {
17
+ background: transparent;
18
+ border: 1px solid var(--border-primary);
19
+ }
20
+ .ui-empty--background {
21
+ background: var(--accent-bg);
22
+ border: 0;
23
+ }
24
+ .ui-empty--outline-dashed {
25
+ background: transparent;
26
+ border: 1px dashed var(--border-primary);
27
+ }
28
+
29
+ /* Inner content stack */
30
+ .ui-empty__content {
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ gap: var(--spacing-md);
35
+ width: 100%;
36
+ }
37
+
38
+ .ui-empty__media {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ }
43
+
44
+ .ui-empty__text {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: var(--spacing-2xs);
48
+ text-align: center;
49
+ width: 100%;
50
+ }
51
+
52
+ .ui-empty__title {
53
+ margin: 0;
54
+ font-family: var(--font-family-body);
55
+ font-size: var(--paragraph-regular-font-size);
56
+ line-height: var(--paragraph-regular-line-height);
57
+ letter-spacing: var(--paragraph-regular-letter-spacing);
58
+ font-weight: var(--paragraph-medium-font-weight);
59
+ color: var(--primary-fg);
60
+ }
61
+
62
+ .ui-empty__description {
63
+ margin: 0;
64
+ font-family: var(--font-family-body);
65
+ font-size: var(--paragraph-small-font-size);
66
+ line-height: var(--paragraph-small-line-height);
67
+ letter-spacing: var(--paragraph-small-letter-spacing);
68
+ font-weight: var(--paragraph-font-weight);
69
+ color: var(--muted-fg);
70
+ }
71
+
72
+ /* Footer slot — stacks action group(s) below the text block.
73
+ Consumer composes button rows / link rows freely; gap matches the
74
+ outer content stack so a single child OR multiple children line up. */
75
+ .ui-empty__footer {
76
+ display: flex;
77
+ flex-direction: column;
78
+ align-items: center;
79
+ gap: var(--spacing-md);
80
+ }
@@ -0,0 +1,16 @@
1
+ <template>
2
+ <span class="ui-empty-icon">
3
+ <span class="ui-empty-icon__frame">
4
+ <slot></slot>
5
+ </span>
6
+ </span>
7
+ </template>
8
+
9
+ <script>
10
+ export default {
11
+ name: 'EmptyIcon',
12
+ emits: []
13
+ }
14
+ </script>
15
+
16
+ <style src="./empty-icon.css" scoped></style>
@@ -0,0 +1,30 @@
1
+ .ui-empty-icon {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ width: 40px;
6
+ height: 40px;
7
+ padding: var(--spacing-xs);
8
+ background: var(--secondary-bg);
9
+ border-radius: var(--rounded-lg);
10
+ box-sizing: border-box;
11
+ flex-shrink: 0;
12
+ }
13
+
14
+ /* Inner frame fixes the icon at the Figma-specified 24x24. Consumers
15
+ place their <svg> (or <img>) directly in the default slot; the frame
16
+ normalises sizing so different icons line up. */
17
+ .ui-empty-icon__frame {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ width: 24px;
22
+ height: 24px;
23
+ color: var(--primary-fg);
24
+ }
25
+
26
+ .ui-empty-icon__frame ::v-deep svg {
27
+ width: 100%;
28
+ height: 100%;
29
+ display: block;
30
+ }
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <div
3
+ :class="[
4
+ 'ui-field',
5
+ `ui-field--${layout}`,
6
+ layout === 'horizontal' && `ui-field--align-${align}`,
7
+ required && 'ui-field--required',
8
+ isError && 'ui-field--error'
9
+ ]"
10
+ >
11
+ <div
12
+ v-if="hasLabel"
13
+ class="ui-field__label"
14
+ :style="layout === 'horizontal' ? { flexBasis: normalizedLabelWidth, width: normalizedLabelWidth } : null"
15
+ >
16
+ <slot name="label">{{ label }}</slot>
17
+ </div>
18
+ <div class="ui-field__body">
19
+ <div class="ui-field__control">
20
+ <slot></slot>
21
+ </div>
22
+ <div v-if="hasMessage" class="ui-field__message">
23
+ <span class="ui-field__message-icon" aria-hidden="true">
24
+ <slot name="message-icon">
25
+ <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
26
+ <circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.3" />
27
+ <path d="M8 7.25v3.75" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
28
+ <circle cx="8" cy="5.25" r="0.75" fill="currentColor" />
29
+ </svg>
30
+ </slot>
31
+ </span>
32
+ <span class="ui-field__message-text">
33
+ <slot name="message">{{ isError ? errorText : helpText }}</slot>
34
+ </span>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </template>
39
+
40
+ <script>
41
+ export default {
42
+ name: 'Field',
43
+ props: {
44
+ layout: {
45
+ type: String,
46
+ default: 'vertical',
47
+ validator: v => ['vertical', 'horizontal'].includes(v)
48
+ },
49
+ label: { type: String, default: '' },
50
+ required: { type: Boolean, default: false },
51
+ helpText: { type: String, default: '' },
52
+ errorText: { type: String, default: '' },
53
+ labelWidth: { type: [String, Number], default: '120px' },
54
+ align: {
55
+ type: String,
56
+ default: 'center',
57
+ validator: v => ['start', 'center'].includes(v)
58
+ }
59
+ },
60
+ emits: [],
61
+ computed: {
62
+ hasLabel() {
63
+ return !!this.label || !!((this.$scopedSlots && this.$scopedSlots.label) || this.$slots.label)
64
+ },
65
+ isError() {
66
+ return !!this.errorText
67
+ },
68
+ hasMessage() {
69
+ return (
70
+ !!this.errorText ||
71
+ !!this.helpText ||
72
+ !!((this.$scopedSlots && this.$scopedSlots.message) || this.$slots.message)
73
+ )
74
+ },
75
+ normalizedLabelWidth() {
76
+ return typeof this.labelWidth === 'number' ? `${this.labelWidth}px` : this.labelWidth
77
+ }
78
+ }
79
+ }
80
+ </script>
81
+
82
+ <style src="./field.css" scoped></style>
@@ -0,0 +1,87 @@
1
+ .ui-field {
2
+ display: flex;
3
+ gap: var(--spacing-2xs);
4
+ width: 100%;
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ /* Vertical: label on top, body below */
9
+ .ui-field--vertical {
10
+ flex-direction: column;
11
+ align-items: stretch;
12
+ }
13
+
14
+ /* Horizontal: label on the left, body on the right */
15
+ .ui-field--horizontal {
16
+ flex-direction: row;
17
+ }
18
+ .ui-field--horizontal.ui-field--align-center {
19
+ align-items: center;
20
+ }
21
+ .ui-field--horizontal.ui-field--align-start {
22
+ align-items: flex-start;
23
+ }
24
+
25
+ /* Label */
26
+ .ui-field__label {
27
+ flex-shrink: 0;
28
+ font-family: var(--font-family-body);
29
+ font-size: var(--paragraph-small-font-size);
30
+ line-height: var(--paragraph-small-line-height);
31
+ letter-spacing: var(--paragraph-small-letter-spacing);
32
+ font-weight: var(--paragraph-medium-font-weight);
33
+ color: var(--primary-fg);
34
+ }
35
+ .ui-field--required .ui-field__label {
36
+ color: var(--destructive-text);
37
+ }
38
+
39
+ /* Body wraps the control + inline message so both align under the label
40
+ column in horizontal layout, and stack tightly in vertical layout. */
41
+ .ui-field__body {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: var(--spacing-2xs);
45
+ flex: 1 1 0;
46
+ min-width: 0;
47
+ }
48
+
49
+ .ui-field__control {
50
+ width: 100%;
51
+ }
52
+
53
+ /* Inline message: 16px icon + text. Defaults to muted; turns destructive on error. */
54
+ .ui-field__message {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: var(--spacing-xs);
58
+ font-family: var(--font-family-body);
59
+ font-size: var(--paragraph-small-font-size);
60
+ line-height: var(--paragraph-small-line-height);
61
+ letter-spacing: var(--paragraph-small-letter-spacing);
62
+ font-weight: var(--paragraph-font-weight);
63
+ color: var(--muted-fg);
64
+ }
65
+ .ui-field--error .ui-field__message {
66
+ color: var(--destructive-text);
67
+ }
68
+
69
+ .ui-field__message-icon {
70
+ display: inline-flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ width: 16px;
74
+ height: 16px;
75
+ flex-shrink: 0;
76
+ color: inherit;
77
+ }
78
+ .ui-field__message-icon ::v-deep svg {
79
+ width: 100%;
80
+ height: 100%;
81
+ display: block;
82
+ }
83
+
84
+ .ui-field__message-text {
85
+ flex: 1 1 0;
86
+ min-width: 0;
87
+ }
@@ -0,0 +1,149 @@
1
+ <template>
2
+ <span class="ui-tooltip">
3
+ <span
4
+ ref="trigger"
5
+ class="ui-tooltip__trigger"
6
+ @mouseenter="onShow"
7
+ @mouseleave="onHide"
8
+ @focusin="onShow"
9
+ @focusout="onHide"
10
+ >
11
+ <slot></slot>
12
+ </span>
13
+ <span
14
+ ref="tooltip"
15
+ v-show="isVisible"
16
+ :class="[
17
+ 'ui-tooltip__content',
18
+ `ui-tooltip__content--${color}`,
19
+ `ui-tooltip__content--${side}`,
20
+ hasMaxWidth && 'ui-tooltip__content--wrap'
21
+ ]"
22
+ :style="tooltipStyle"
23
+ role="tooltip"
24
+ >
25
+ <span class="ui-tooltip__text">
26
+ <slot name="content">{{ title }}</slot>
27
+ </span>
28
+ <span v-if="arrow" :class="['ui-tooltip__arrow', `ui-tooltip__arrow--${side}`]" aria-hidden="true"></span>
29
+ </span>
30
+ </span>
31
+ </template>
32
+
33
+ <script>
34
+ const GAP = 4
35
+
36
+ export default {
37
+ name: 'Tooltip',
38
+ props: {
39
+ side: {
40
+ type: String,
41
+ default: 'top',
42
+ validator: v => ['top', 'bottom', 'left', 'right'].includes(v)
43
+ },
44
+ title: { type: String, default: '' },
45
+ maxWidth: { type: [String, Number], default: '' },
46
+ open: { type: Boolean, default: false },
47
+ color: {
48
+ type: String,
49
+ default: 'default',
50
+ validator: v => ['default', 'brand', 'destructive'].includes(v)
51
+ },
52
+ arrow: { type: Boolean, default: true }
53
+ },
54
+ emits: [],
55
+ data() {
56
+ return {
57
+ hovered: false,
58
+ positionStyle: {}
59
+ }
60
+ },
61
+ computed: {
62
+ isVisible() {
63
+ return this.open || this.hovered
64
+ },
65
+ hasMaxWidth() {
66
+ return this.maxWidth !== '' && this.maxWidth !== null && this.maxWidth !== undefined
67
+ },
68
+ tooltipStyle() {
69
+ const style = Object.assign({}, this.positionStyle)
70
+ if (this.hasMaxWidth) {
71
+ style.maxWidth = typeof this.maxWidth === 'number' ? `${this.maxWidth}px` : this.maxWidth
72
+ }
73
+ return style
74
+ }
75
+ },
76
+ watch: {
77
+ isVisible(v) {
78
+ if (v) this.$nextTick(this.updatePosition)
79
+ },
80
+ side() {
81
+ if (this.isVisible) this.$nextTick(this.updatePosition)
82
+ }
83
+ },
84
+ mounted() {
85
+ if (typeof document !== 'undefined' && this.$refs.tooltip) {
86
+ document.body.appendChild(this.$refs.tooltip)
87
+ }
88
+ window.addEventListener('scroll', this.onScrollOrResize, true)
89
+ window.addEventListener('resize', this.onScrollOrResize)
90
+ if (this.open) this.$nextTick(this.updatePosition)
91
+ if (typeof this.$on === 'function') {
92
+ // eslint-disable-next-line vue/no-deprecated-events-api
93
+ this.$on('hook:beforeDestroy', this.cleanup)
94
+ }
95
+ },
96
+ beforeUnmount() {
97
+ this.cleanup()
98
+ },
99
+ methods: {
100
+ onShow() {
101
+ this.hovered = true
102
+ },
103
+ onHide() {
104
+ this.hovered = false
105
+ },
106
+ onScrollOrResize() {
107
+ if (this.isVisible) this.updatePosition()
108
+ },
109
+ updatePosition() {
110
+ const trigger = this.$refs.trigger
111
+ const tip = this.$refs.tooltip
112
+ if (!trigger || !tip) return
113
+ const t = trigger.getBoundingClientRect()
114
+ const tw = tip.offsetWidth
115
+ const th = tip.offsetHeight
116
+ let top = 0
117
+ let left = 0
118
+ if (this.side === 'top') {
119
+ top = t.top - th - GAP
120
+ left = t.left + (t.width - tw) / 2
121
+ } else if (this.side === 'bottom') {
122
+ top = t.bottom + GAP
123
+ left = t.left + (t.width - tw) / 2
124
+ } else if (this.side === 'left') {
125
+ top = t.top + (t.height - th) / 2
126
+ left = t.left - tw - GAP
127
+ } else {
128
+ top = t.top + (t.height - th) / 2
129
+ left = t.right + GAP
130
+ }
131
+ this.positionStyle = {
132
+ position: 'fixed',
133
+ top: top + 'px',
134
+ left: left + 'px',
135
+ zIndex: 1050
136
+ }
137
+ },
138
+ cleanup() {
139
+ window.removeEventListener('scroll', this.onScrollOrResize, true)
140
+ window.removeEventListener('resize', this.onScrollOrResize)
141
+ if (this.$refs.tooltip && this.$refs.tooltip.parentNode === document.body) {
142
+ document.body.removeChild(this.$refs.tooltip)
143
+ }
144
+ }
145
+ }
146
+ }
147
+ </script>
148
+
149
+ <style src="./tooltip.css" scoped></style>
@@ -0,0 +1,102 @@
1
+ /* Wrapper just anchors the trigger inline. The content bubble is moved
2
+ out to <body> at mount time so it can never be clipped by overflow
3
+ on an ancestor. Position is computed in JS. */
4
+ .ui-tooltip {
5
+ display: inline-flex;
6
+ vertical-align: middle;
7
+ }
8
+
9
+ .ui-tooltip__trigger {
10
+ display: inline-flex;
11
+ }
12
+
13
+ /* Color tokens cascade via a local CSS variable so the arrow inherits
14
+ the same background and stays in sync with the bubble. */
15
+ .ui-tooltip__content--default {
16
+ --_tooltip-color-bg: var(--tooltip);
17
+ --_tooltip-color-fg: var(--tooltip-foreground);
18
+ }
19
+ .ui-tooltip__content--brand {
20
+ --_tooltip-color-bg: var(--primary-brand-bg);
21
+ --_tooltip-color-fg: var(--inverse-fg);
22
+ }
23
+ .ui-tooltip__content--destructive {
24
+ --_tooltip-color-bg: var(--destructive);
25
+ --_tooltip-color-fg: var(--destructive-inverse-fg);
26
+ }
27
+
28
+ .ui-tooltip__content {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: var(--spacing-xs);
32
+ padding: var(--spacing-6) var(--spacing-xs);
33
+ background: var(--_tooltip-color-bg);
34
+ color: var(--_tooltip-color-fg);
35
+ font-family: var(--font-family-body);
36
+ font-size: var(--paragraph-mini-font-size);
37
+ line-height: var(--paragraph-mini-line-height);
38
+ letter-spacing: var(--paragraph-mini-letter-spacing);
39
+ font-weight: var(--paragraph-font-weight);
40
+ border-radius: var(--rounded-lg);
41
+ white-space: nowrap;
42
+ pointer-events: none;
43
+ }
44
+
45
+ .ui-tooltip__content--wrap {
46
+ white-space: normal;
47
+ }
48
+
49
+ .ui-tooltip__text {
50
+ display: inline-block;
51
+ }
52
+
53
+ /* Arrow as a CSS triangle. The pointing border uses the inherited
54
+ color variable so it always matches the bubble background. */
55
+ .ui-tooltip__arrow {
56
+ position: absolute;
57
+ width: 0;
58
+ height: 0;
59
+ }
60
+
61
+ .ui-tooltip__arrow--top {
62
+ bottom: -5px;
63
+ left: 50%;
64
+ transform: translateX(-50%);
65
+ border-left: 6px solid transparent;
66
+ border-right: 6px solid transparent;
67
+ border-top: 5px solid var(--_tooltip-color-bg);
68
+ }
69
+ .ui-tooltip__arrow--bottom {
70
+ top: -5px;
71
+ left: 50%;
72
+ transform: translateX(-50%);
73
+ border-left: 6px solid transparent;
74
+ border-right: 6px solid transparent;
75
+ border-bottom: 5px solid var(--_tooltip-color-bg);
76
+ }
77
+ .ui-tooltip__arrow--left {
78
+ right: -5px;
79
+ top: 50%;
80
+ transform: translateY(-50%);
81
+ border-top: 6px solid transparent;
82
+ border-bottom: 6px solid transparent;
83
+ border-left: 5px solid var(--_tooltip-color-bg);
84
+ }
85
+ .ui-tooltip__arrow--right {
86
+ left: -5px;
87
+ top: 50%;
88
+ transform: translateY(-50%);
89
+ border-top: 6px solid transparent;
90
+ border-bottom: 6px solid transparent;
91
+ border-right: 5px solid var(--_tooltip-color-bg);
92
+ }
93
+
94
+ /* The bubble itself needs to be position-context for the arrow.
95
+ We rely on the inline `position: fixed` set by the positioning logic
96
+ in JS, but we also want consumers to be able to apply a manual
97
+ transform if needed — the arrow's position uses absolute, which is
98
+ fine because the bubble's fixed position establishes a containing
99
+ block for its descendants. */
100
+ .ui-tooltip__content {
101
+ position: relative;
102
+ }
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { default as WkAccordion } from './components/accordion/Accordion.vue'
2
2
  export { default as WkAccordionItem } from './components/accordion-item/AccordionItem.vue'
3
3
  export { default as WkAlertDialog } from './components/alert-dialog/AlertDialog.vue'
4
+ export { default as WkAvatar } from './components/avatar/Avatar.vue'
5
+ export { default as WkAvatarStack } from './components/avatar-stack/AvatarStack.vue'
4
6
  export { default as WkBadge } from './components/badge/Badge.vue'
5
7
  export { default as WkBreadcrumb } from './components/breadcrumb/Breadcrumb.vue'
6
8
  export { default as WkButton } from './components/button/Button.vue'
@@ -9,6 +11,9 @@ export { default as WkCheckbox } from './components/checkbox/Checkbox.vue'
9
11
  export { default as WkCheckboxGroup } from './components/checkbox-group/CheckboxGroup.vue'
10
12
  export { default as WkDialog } from './components/dialog/Dialog.vue'
11
13
  export { default as WkDivider } from './components/divider/Divider.vue'
14
+ export { default as WkEmpty } from './components/empty/Empty.vue'
15
+ export { default as WkEmptyIcon } from './components/empty-icon/EmptyIcon.vue'
16
+ export { default as WkField } from './components/field/Field.vue'
12
17
  export { default as WkInput } from './components/input/Input.vue'
13
18
  export { default as WkPagination } from './components/pagination/Pagination.vue'
14
19
  export { default as WkRadio } from './components/radio/Radio.vue'
@@ -27,4 +32,5 @@ export { default as WkTabs } from './components/tabs/Tabs.vue'
27
32
  export { default as WkTag } from './components/tag/Tag.vue'
28
33
  export { default as WkToggle } from './components/toggle/Toggle.vue'
29
34
  export { default as WkToggleGroup } from './components/toggle-group/ToggleGroup.vue'
35
+ export { default as WkTooltip } from './components/tooltip/Tooltip.vue'
30
36
  export { default as WkTypography } from './components/typography/Typography.vue'
@@ -79,6 +79,8 @@
79
79
  --button-black: var(--color-neutral-950);
80
80
  --button-black-fg: var(--color-white-alpha-95);
81
81
  --button-black-hover: var(--color-neutral-700);
82
+ --tooltip: var(--color-black-alpha-100);
83
+ --tooltip-foreground: var(--color-white-alpha-100);
82
84
  --card: var(--color-white-alpha-100);
83
85
  --card-foreground: var(--color-neutral-950);
84
86
 
@@ -184,6 +186,8 @@
184
186
  --button-black: var(--color-neutral-50);
185
187
  --button-black-fg: var(--color-black-alpha-95);
186
188
  --button-black-hover: var(--color-neutral-200);
189
+ --tooltip: var(--color-white-alpha-100);
190
+ --tooltip-foreground: var(--color-black-alpha-100);
187
191
  --card: var(--color-neutral-900);
188
192
  --card-foreground: var(--color-white-alpha-100);
189
193