@kiva/kv-components 3.92.1 → 3.94.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [3.94.0](https://github.com/kiva/kv-ui-elements/compare/@kiva/kv-components@3.93.0...@kiva/kv-components@3.94.0) (2024-08-23)
7
+
8
+
9
+ ### Features
10
+
11
+ * charts added to library ([#447](https://github.com/kiva/kv-ui-elements/issues/447)) ([575bd80](https://github.com/kiva/kv-ui-elements/commit/575bd80fb1700a38732a2b8cafbeb5cc5374764b))
12
+
13
+
14
+
15
+
16
+
17
+ # [3.93.0](https://github.com/kiva/kv-ui-elements/compare/@kiva/kv-components@3.92.1...@kiva/kv-components@3.93.0) (2024-08-22)
18
+
19
+
20
+ ### Features
21
+
22
+ * carousel circle effect ([c982be4](https://github.com/kiva/kv-ui-elements/commit/c982be4d088f18cb53b5d97b3071fa7dac72e1c6))
23
+
24
+
25
+
26
+
27
+
6
28
  ## [3.92.1](https://github.com/kiva/kv-ui-elements/compare/@kiva/kv-components@3.92.0...@kiva/kv-components@3.92.1) (2024-08-19)
7
29
 
8
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kiva/kv-components",
3
- "version": "3.92.1",
3
+ "version": "3.94.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -71,6 +71,8 @@
71
71
  "moment": "^2.29.4",
72
72
  "nanoid": "^3.1.23",
73
73
  "numeral": "^2.0.6",
74
+ "popper.js": "^1.16.1",
75
+ "treemap-squarify": "^1.0.1",
74
76
  "vue-demi": "^0.14.7"
75
77
  },
76
78
  "peerDependencies": {
@@ -82,5 +84,5 @@
82
84
  "optional": true
83
85
  }
84
86
  },
85
- "gitHead": "52085aeb1c92162fd732cd3fe4cb9839cdd49b16"
87
+ "gitHead": "4d6b8d16d7fe0e5b3904b87a82484e28325cd496"
86
88
  }
package/utils/Alea.js ADDED
@@ -0,0 +1,92 @@
1
+ /* eslint-disable no-plusplus, no-param-reassign, no-return-assign, no-bitwise, prefer-rest-params, func-names */
2
+
3
+ /**
4
+ * The hash function used for Alea pseudo random number generator
5
+ *
6
+ * Johannes Baagøe <baagoe@baagoe.com>, 2010
7
+ * Licensed under the MIT license
8
+ *
9
+ * {@link https://github.com/nquinlan/better-random-numbers-for-javascript-mirror}
10
+ *
11
+ * @returns The hash of the provided data
12
+ */
13
+ export function Mash() {
14
+ let n = 0xefc8249d;
15
+
16
+ const mash = function (data) {
17
+ data = data.toString();
18
+ for (let i = 0; i < data.length; i++) {
19
+ n += data.charCodeAt(i);
20
+ let h = 0.02519603282416938 * n;
21
+ n = h >>> 0;
22
+ h -= n;
23
+ h *= n;
24
+ n = h >>> 0;
25
+ h -= n;
26
+ n += h * 0x100000000; // 2^32
27
+ }
28
+ return (n >>> 0) * 2.3283064365386963e-10; // 2^-32
29
+ };
30
+
31
+ mash.version = 'Mash 0.9';
32
+
33
+ return mash;
34
+ }
35
+
36
+ /**
37
+ * Pseudo random number generator that returns a repeatable set of random numbers when supplied matching seeds
38
+ *
39
+ * Johannes Baagøe <baagoe@baagoe.com>, 2010
40
+ * Licensed under the MIT license
41
+ *
42
+ * {@link https://github.com/nquinlan/better-random-numbers-for-javascript-mirror}
43
+ *
44
+ * @returns The seeded generator function
45
+ */
46
+ export default function Alea() {
47
+ return (function (args) {
48
+ let s0 = 0;
49
+ let s1 = 0;
50
+ let s2 = 0;
51
+ let c = 1;
52
+
53
+ if (args.length === 0) {
54
+ args = [+new Date()];
55
+ }
56
+
57
+ let mash = Mash();
58
+ s0 = mash(' ');
59
+ s1 = mash(' ');
60
+ s2 = mash(' ');
61
+
62
+ for (let i = 0; i < args.length; i++) {
63
+ s0 -= mash(args[i]);
64
+ if (s0 < 0) {
65
+ s0 += 1;
66
+ }
67
+ s1 -= mash(args[i]);
68
+ if (s1 < 0) {
69
+ s1 += 1;
70
+ }
71
+ s2 -= mash(args[i]);
72
+ if (s2 < 0) {
73
+ s2 += 1;
74
+ }
75
+ }
76
+
77
+ mash = null;
78
+
79
+ const random = function () {
80
+ const t = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32
81
+ s0 = s1;
82
+ s1 = s2;
83
+ return s2 = t - (c = t | 0);
84
+ };
85
+
86
+ random.version = 'Alea 0.9';
87
+
88
+ random.args = args;
89
+
90
+ return random;
91
+ }(Array.prototype.slice.call(arguments)));
92
+ }
@@ -0,0 +1,22 @@
1
+ // Attach a touchstart event handler to the immediate children of the body.
2
+ // Useful for capturing the user tapping outside of a target element.
3
+ export function onBodyTouchstart(handler) {
4
+ [...document.body.children].forEach((child) => child.addEventListener('touchstart', handler));
5
+ }
6
+
7
+ // Remove a touchstart event handler from the immediate children of the body
8
+ export function offBodyTouchstart(handler) {
9
+ [...document.body.children].forEach((child) => child.removeEventListener('touchstart', handler));
10
+ }
11
+
12
+ // Returns true if the event target is any of the given elements or if the event target
13
+ // is contained by any of the given elements.
14
+ export function isTargetElement(event, elements) {
15
+ const els = Array.isArray(elements) ? elements : [elements];
16
+ for (let i = 0; i < els.length; i += 1) {
17
+ if (els[i] === event.target || els[i].contains(event.target)) {
18
+ return true;
19
+ }
20
+ }
21
+ return false;
22
+ }
@@ -8,7 +8,10 @@
8
8
  <!-- Carousel Content -->
9
9
  <div
10
10
  class="tw-flex tw-gap-x-4"
11
- :class="{ 'tw-mx-auto aside-controls-content': asideControls }"
11
+ :class="{
12
+ 'tw-mx-auto aside-controls-content': asideControls,
13
+ 'circle-carousel': inCircle
14
+ }"
12
15
  @click.capture="onCarouselContainerClick"
13
16
  >
14
17
  <div
@@ -20,7 +23,7 @@
20
23
  :aria-current="currentIndex === index ? 'true' : 'false'"
21
24
  :aria-hidden="isAriaHidden(index)? 'true' : 'false'"
22
25
  :tab-index="isAriaHidden(index) ? '-1' : false"
23
- :class="{ 'tw-w-full': !multipleSlidesVisible || slideMaxWidth }"
26
+ :class="{ 'tw-w-full': !multipleSlidesVisible || slideMaxWidth, 'cirle-slide': inCircle }"
24
27
  :style="slideMaxWidth ? `max-width:${slideMaxWidth}` :''"
25
28
  >
26
29
  <slot
@@ -219,6 +222,13 @@ export default {
219
222
  type: Boolean,
220
223
  default: false,
221
224
  },
225
+ /**
226
+ * Enables carousel slides to have a circle effect
227
+ * */
228
+ inCircle: {
229
+ type: Boolean,
230
+ default: false,
231
+ },
222
232
  },
223
233
  emits: [
224
234
  'change',
@@ -414,4 +424,23 @@ export default {
414
424
  width: 82%;
415
425
  }
416
426
  }
427
+
428
+ .cirle-slide {
429
+ width: auto;
430
+ }
431
+
432
+ .cirle-slide.is-selected >>> img {
433
+ opacity: 1;
434
+ transform: scale(1);
435
+ max-width: 300px;
436
+ }
437
+
438
+ .cirle-slide:not(.is-selected) >>> img {
439
+ opacity: 0.5;
440
+ transform: scale(0.5);
441
+ }
442
+
443
+ .circle-carousel {
444
+ margin: 0 auto;
445
+ }
417
446
  </style>
@@ -0,0 +1,232 @@
1
+ <template>
2
+ <figure
3
+ class="pie-chart tw-flex tw-flex-col md:tw-flex-row tw-items-center tw-justify-center tw-gap-2"
4
+ @mouseleave="activeSlice = null"
5
+ >
6
+ <!-- pie chart -->
7
+ <div class="tw-relative tw-h-full">
8
+ <div
9
+ v-if="loading"
10
+ class="pie-placeholder tw-h-full tw-p-2.5"
11
+ >
12
+ <div class="tw-overflow-hidden tw-rounded-full tw-h-full">
13
+ <kv-loading-placeholder />
14
+ </div>
15
+ </div>
16
+ <svg
17
+ v-else
18
+ class="tw-h-full"
19
+ viewBox="0 0 32 32"
20
+ xmlns="http://www.w3.org/2000/svg"
21
+ xmlns:xlink="http://www.w3.org/1999/xlink"
22
+ >
23
+ <path
24
+ v-for="(slice, index) in slices"
25
+ :key="index"
26
+ class="tw-origin-center tw-transition-transform"
27
+ :style="activeSlice === slice ? { transform: 'scale(1.1)' } : {}"
28
+ :d="slice.path"
29
+ :stroke="slice.color"
30
+ :stroke-width="lineWidth"
31
+ fill="none"
32
+ @mouseenter="activeSlice = slice"
33
+ @click="activeSlice = slice"
34
+ />
35
+ </svg>
36
+ <!-- active slice -->
37
+ <div
38
+ v-if="activeSlice"
39
+ class="
40
+ tw-absolute tw-top-1/2 tw-left-1/2 -tw-translate-x-1/2 -tw-translate-y-1/2
41
+ tw-flex tw-flex-col tw-items-center tw-text-center"
42
+ style="max-width: 8.5rem;"
43
+ >
44
+ <div class="tw-font-medium tw-line-clamp-4">
45
+ {{ activeSlice.label }}
46
+ </div>
47
+ <div>
48
+ {{ activeSlice.value }} ({{ activeSlicePercent }})
49
+ </div>
50
+ </div>
51
+ </div>
52
+ <!-- key -->
53
+ <div
54
+ style="width: 14rem; height: 85%;"
55
+ class="tw-flex tw-flex-col tw-justify-between"
56
+ >
57
+ <ol class="tw-pl-4 md:tw-pl-0">
58
+ <li
59
+ v-for="(slice, index) in slices.slice(pageIndex * slicesPerPage, (pageIndex + 1) * slicesPerPage)"
60
+ :key="index"
61
+ class="tw-flex tw-items-center"
62
+ @mouseenter="activeSlice = slice"
63
+ @click="activeSlice = slice"
64
+ >
65
+ <div
66
+ class="tw-w-2 tw-h-2 tw-mr-1 tw-rounded-full tw-flex-none"
67
+ :style="{ backgroundColor: slice.color }"
68
+ ></div>
69
+ <span class="tw-truncate">
70
+ {{ slice.label }}
71
+ </span>
72
+ </li>
73
+ </ol>
74
+ <!-- paging controls -->
75
+ <div
76
+ v-if="pageCount > 1"
77
+ class="tw-flex tw-justify-center md:tw-justify-start"
78
+ >
79
+ <button
80
+ :disabled="pageIndex === 0"
81
+ class="tw-font-medium tw-p-0.5 disabled:tw-opacity-low"
82
+ @click="prevPage"
83
+ >
84
+ &lt;
85
+ </button>
86
+ {{ pageIndex + 1 }} / {{ pageCount }}
87
+ <button
88
+ :disabled="pageIndex === pageCount - 1"
89
+ class="tw-font-medium tw-p-0.5 disabled:tw-opacity-low"
90
+ @click="nextPage"
91
+ >
92
+ &gt;
93
+ </button>
94
+ </div>
95
+ </div>
96
+ </figure>
97
+ </template>
98
+
99
+ <script>
100
+ import numeral from 'numeral';
101
+ import { ref, toRefs, computed } from 'vue-demi';
102
+ import Alea from '../utils/Alea';
103
+ import KvLoadingPlaceholder from './KvLoadingPlaceholder.vue';
104
+
105
+ // convenience function to get point on circumference of a given circle (from https://codepen.io/grieve/pen/xwGMJp)
106
+ function circumPointFromAngle(cx, cy, r, a) {
107
+ return [
108
+ cx + r * Math.cos(a),
109
+ cy + r * Math.sin(a),
110
+ ];
111
+ }
112
+
113
+ export default {
114
+ name: 'PieChartFigure',
115
+ components: {
116
+ KvLoadingPlaceholder,
117
+ },
118
+ props: {
119
+ loading: {
120
+ type: Boolean,
121
+ default: true,
122
+ },
123
+ values: {
124
+ type: Array,
125
+ default: () => ([]),
126
+ },
127
+ },
128
+ setup(props) {
129
+ const {
130
+ values,
131
+ } = toRefs(props);
132
+
133
+ const svgSize = ref(32);
134
+ const lineWidth = ref(5);
135
+ const activeSlice = ref(null);
136
+ const pageIndex = ref(0);
137
+ const slicesPerPage = ref(10);
138
+
139
+ const activeSlicePercent = computed(() => {
140
+ return activeSlice.value ? numeral(activeSlice.value.percent).format('0.00%') : '';
141
+ });
142
+
143
+ const radius = computed(() => {
144
+ return (svgSize.value / 2) - (lineWidth.value / 2) - 2;
145
+ });
146
+
147
+ const center = computed(() => {
148
+ return svgSize.value / 2;
149
+ });
150
+
151
+ const pickColor = (index) => {
152
+ const rng = new Alea('loans', index, 'kiva');
153
+ let color = '#';
154
+ for (let i = 0; i < 3; i += 1) {
155
+ color += Math.floor(rng() * 256).toString(16).padStart(2, '0');
156
+ }
157
+ return color;
158
+ };
159
+
160
+ const slices = computed(() => {
161
+ const slicesArr = [];
162
+ // center point
163
+ const cX = center.value;
164
+ const cY = cX;
165
+ // radius
166
+ const r = radius.value;
167
+ // starting angle
168
+ let start = -0.25;
169
+ // loop through each value and create a pie slice path
170
+ for (let i = 0; i < values.value.length; i += 1) {
171
+ const value = values.value[i];
172
+ const end = start + value.percent;
173
+ const [startX, startY] = circumPointFromAngle(cX, cY, r, start * Math.PI * 2);
174
+ const [endX, endY] = circumPointFromAngle(cX, cY, r, end * Math.PI * 2);
175
+ const largeArc = value.percent > 0.5 ? 1 : 0;
176
+ // Draw just the outer arc of the slice
177
+ const path = `M ${startX} ${startY} A ${r} ${r} 0 ${largeArc} 1 ${endX} ${endY}`;
178
+ slicesArr.push({
179
+ ...value,
180
+ path,
181
+ color: pickColor(i),
182
+ });
183
+ start = end;
184
+ }
185
+ return slicesArr;
186
+ });
187
+
188
+ const pageCount = computed(() => {
189
+ return Math.ceil(slices.value.length / slicesPerPage.value);
190
+ });
191
+
192
+ const prevPage = () => {
193
+ if (pageIndex.value > 0) {
194
+ pageIndex.value -= 1;
195
+ }
196
+ };
197
+ const nextPage = () => {
198
+ if (pageIndex.value < pageCount.value - 1) {
199
+ pageIndex.value += 1;
200
+ }
201
+ };
202
+
203
+ return {
204
+ svgSize,
205
+ lineWidth,
206
+ activeSlice,
207
+ pageIndex,
208
+ slicesPerPage,
209
+ activeSlicePercent,
210
+ radius,
211
+ center,
212
+ slices,
213
+ pageCount,
214
+ prevPage,
215
+ nextPage,
216
+ };
217
+ },
218
+ };
219
+ </script>
220
+
221
+ <style lang="postcss" scoped>
222
+ .pie-chart {
223
+ height: 40rem;
224
+ @screen md {
225
+ height: 20rem;
226
+ }
227
+ }
228
+
229
+ .pie-placeholder {
230
+ width: 20rem;
231
+ }
232
+ </style>
@@ -0,0 +1,178 @@
1
+ <template>
2
+ <transition :name="transitionType">
3
+ <div
4
+ v-show="show"
5
+ class="tw-absolute"
6
+ :style="styles"
7
+ :aria-hidden="show ? 'false' : 'true'"
8
+ >
9
+ <slot></slot>
10
+ </div>
11
+ </transition>
12
+ </template>
13
+
14
+ <script>
15
+ import {
16
+ onBodyTouchstart,
17
+ offBodyTouchstart,
18
+ isTargetElement,
19
+ } from '../utils/touchEvents';
20
+
21
+ export default {
22
+ name: 'KvPopper',
23
+ props: {
24
+ controller: {
25
+ validator(value) {
26
+ if (typeof value === 'string') return true;
27
+ if (typeof window === 'object'
28
+ && 'HTMLElement' in window
29
+ && value instanceof HTMLElement) return true;
30
+ return false;
31
+ },
32
+ required: true,
33
+ },
34
+ openDelay: { type: Number, default: 0 },
35
+ closeDelay: { type: Number, default: 200 },
36
+ // must be defined in our globa/transitions.scss
37
+ transitionType: { type: String, default: '' },
38
+ popperPlacement: { type: String, default: 'bottom-start' },
39
+ popperModifiers: { type: Object, default: () => {} },
40
+ },
41
+ data() {
42
+ return {
43
+ popper: null,
44
+ popperPromise: null,
45
+ styles: {},
46
+ show: false,
47
+ timeout: null,
48
+ };
49
+ },
50
+ computed: {
51
+ reference() {
52
+ return typeof this.controller === 'string' ? document.getElementById(this.controller) : this.controller;
53
+ },
54
+ },
55
+ watch: {
56
+ show(showing) {
57
+ if (this.reference) {
58
+ this.reference.setAttribute('aria-expanded', showing ? 'true' : 'false');
59
+ }
60
+ this.$emit(showing ? 'show' : 'hide');
61
+ },
62
+ },
63
+ mounted() {
64
+ this.reference.tabIndex = 0;
65
+ this.attachEvents();
66
+ },
67
+ updated() {
68
+ if (this.popper) {
69
+ this.popper.scheduleUpdate();
70
+ }
71
+ },
72
+ beforeDestroy() {
73
+ this.removeEvents();
74
+ if (this.popper) {
75
+ this.popper.destroy();
76
+ }
77
+ },
78
+ methods: {
79
+ open() {
80
+ this.initPopper().then(() => {
81
+ this.setTimeout(() => {
82
+ this.show = true;
83
+ this.popper.scheduleUpdate();
84
+ this.attachBodyEvents();
85
+ }, this.openDelay);
86
+ });
87
+ },
88
+ close() {
89
+ this.setTimeout(() => {
90
+ this.show = false;
91
+ this.removeBodyEvents();
92
+ }, this.closeDelay);
93
+ },
94
+ toggle() {
95
+ if (this.show) {
96
+ this.close();
97
+ } else {
98
+ this.open();
99
+ }
100
+ },
101
+ update() {
102
+ if (this.popper) {
103
+ this.popper.scheduleUpdate();
104
+ }
105
+ },
106
+ initPopper() {
107
+ // skip loading if popper already created
108
+ if (this.popper) return Promise.resolve();
109
+ // skip loading if loading already started
110
+ if (this.popperPromise) return this.popperPromise;
111
+ // import and init Popper.js
112
+ this.popperPromise = import('popper.js').then(({ default: Popper }) => {
113
+ this.popper = new Popper(this.reference, this.$el, {
114
+ placement: this.popperPlacement,
115
+ modifiers: {
116
+ applyStyle: (data) => {
117
+ this.styles = data.styles;
118
+ this.setAttributes(data.attributes);
119
+ },
120
+ preventOverflow: {
121
+ padding: 0,
122
+ },
123
+ ...this.popperModifiers,
124
+ },
125
+ });
126
+ });
127
+ return this.popperPromise;
128
+ },
129
+ bodyTouchHandler(e) {
130
+ if (!isTargetElement(e, [this.reference, this.$el])) {
131
+ this.show = false;
132
+ this.removeBodyEvents();
133
+ }
134
+ },
135
+ referenceTapHandler(e) {
136
+ e.preventDefault();
137
+ this.toggle();
138
+ },
139
+ attachEvents() {
140
+ this.reference.addEventListener('mouseover', this.open);
141
+ this.reference.addEventListener('focus', this.open);
142
+ this.reference.addEventListener('mouseout', this.close);
143
+ this.reference.addEventListener('blur', this.close);
144
+ this.$el.addEventListener('mouseover', this.open);
145
+ this.$el.addEventListener('mouseout', this.close);
146
+ this.reference.addEventListener('touchstart', this.referenceTapHandler);
147
+ },
148
+ attachBodyEvents() {
149
+ onBodyTouchstart(this.bodyTouchHandler);
150
+ },
151
+ removeEvents() {
152
+ this.removeBodyEvents();
153
+ this.reference.removeEventListener('touchstart', this.referenceTapHandler);
154
+ this.reference.removeEventListener('mouseover', this.open);
155
+ this.reference.removeEventListener('mouseout', this.close);
156
+ this.$el.removeEventListener('mouseover', this.open);
157
+ this.$el.removeEventListener('mouseout', this.close);
158
+ },
159
+ removeBodyEvents() {
160
+ offBodyTouchstart(this.bodyTouchHandler);
161
+ },
162
+ setAttributes(attrs) {
163
+ Object.keys(attrs).forEach((attr) => {
164
+ const value = attrs[attr];
165
+ if (value === false) {
166
+ this.$el.removeAttribute(attr);
167
+ } else {
168
+ this.$el.setAttribute(attr, value);
169
+ }
170
+ });
171
+ },
172
+ setTimeout(fn, delay) {
173
+ window.clearTimeout(this.timeout);
174
+ this.timeout = window.setTimeout(fn, delay);
175
+ },
176
+ },
177
+ };
178
+ </script>
@@ -0,0 +1,168 @@
1
+ <template>
2
+ <kv-theme-provider
3
+ :theme="themeStyle"
4
+ class="kv-tailwind"
5
+ >
6
+ <kv-popper
7
+ :controller="controller"
8
+ :popper-modifiers="popperModifiers"
9
+ popper-placement="top"
10
+ transition-type="kvfastfade"
11
+ class="tooltip-pane tw-absolute tw-bg-primary tw-rounded tw-z-popover"
12
+ >
13
+ <div
14
+ class="tw-p-2.5"
15
+ style="max-width: 250px;"
16
+ >
17
+ <div
18
+ v-if="$slots.title"
19
+ class="tw-text-primary tw-font-medium tw-mb-1.5"
20
+ >
21
+ <slot name="title"></slot>
22
+ </div>
23
+ <div class="tw-text-primary">
24
+ <slot></slot>
25
+ </div>
26
+ </div>
27
+ <div
28
+ class="tooltip-arrow tw-absolute tw-w-0 tw-h-0 tw-border-solid"
29
+ x-arrow=""
30
+ ></div>
31
+ </kv-popper>
32
+ </kv-theme-provider>
33
+ </template>
34
+
35
+ <script>
36
+ import { ref, toRefs, computed } from 'vue-demi';
37
+ import {
38
+ darkTheme,
39
+ mintTheme,
40
+ } from '@kiva/kv-tokens/configs/kivaColors.cjs';
41
+ import KvPopper from './KvPopper.vue';
42
+ import KvThemeProvider from './KvThemeProvider.vue';
43
+
44
+ export default {
45
+ name: 'KvTooltip',
46
+ components: {
47
+ KvPopper,
48
+ KvThemeProvider,
49
+ },
50
+ // TODO: Add prop for tooltip placement, Currently defaults to 'top' but will flip to bottom when constrained
51
+ props: {
52
+ controller: {
53
+ validator(value) {
54
+ if (typeof value === 'string') return true;
55
+ if (typeof window !== 'undefined'
56
+ && 'HTMLElement' in window
57
+ && value instanceof HTMLElement) return true;
58
+ return false;
59
+ },
60
+ required: true,
61
+ },
62
+ theme: {
63
+ type: String,
64
+ default: 'default',
65
+ validator(value) {
66
+ // The value must match one of these strings
67
+ return ['default', 'mint', 'dark'].indexOf(value) !== -1;
68
+ },
69
+ },
70
+ },
71
+ setup(props) {
72
+ const {
73
+ theme,
74
+ } = toRefs(props);
75
+
76
+ const popperModifiers = ref({
77
+ preventOverflow: {
78
+ padding: 10,
79
+ },
80
+ });
81
+
82
+ const themeStyle = computed(() => {
83
+ const themeMapper = {
84
+ mint: mintTheme,
85
+ dark: darkTheme,
86
+ };
87
+ return themeMapper[theme.value];
88
+ });
89
+
90
+ return {
91
+ darkTheme,
92
+ mintTheme,
93
+ popperModifiers,
94
+ themeStyle,
95
+ };
96
+ },
97
+ };
98
+ </script>
99
+
100
+ <style lang="postcss" scoped>
101
+ .tooltip-pane,
102
+ .tooltip-arrow {
103
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
104
+ }
105
+
106
+ .tooltip-arrow {
107
+ @apply tw-m-1;
108
+ @apply tw-border-white;
109
+ }
110
+
111
+ /* Top Tooltip Arrow appears on Bottom */
112
+ .tooltip-pane[x-placement^="top"] {
113
+ @apply tw-mb-1;
114
+ }
115
+
116
+ .tooltip-pane[x-placement^="top"] .tooltip-arrow {
117
+ border-width: 8px 8px 0 8px;
118
+ border-left-color: transparent;
119
+ border-right-color: transparent;
120
+ border-bottom-color: transparent;
121
+ left: calc(50% - 8px);
122
+ @apply -tw-bottom-1 tw-mt-0 tw-mb-0;
123
+ }
124
+
125
+ /* Bottom Tooltip Arrow appears on Top */
126
+ .tooltip-pane[x-placement^="bottom"] {
127
+ @apply tw-mt-1;
128
+ }
129
+
130
+ .tooltip-pane[x-placement^="bottom"] .tooltip-arrow {
131
+ border-width: 0 8px 8px 8px;
132
+ border-left-color: transparent;
133
+ border-right-color: transparent;
134
+ border-top-color: transparent;
135
+ left: calc(50% - 8px);
136
+ @apply -tw-top-1 tw-mb-0 tw-mt-0;
137
+ }
138
+
139
+ /* TODO: TWEAK Inner Arrow Styles for Left + Right Orientations */
140
+
141
+ /* Right Side Tooltip, Arrow appears on Left */
142
+ .tooltip-pane[x-placement^="right"] {
143
+ @apply tw-ml-1;
144
+ }
145
+
146
+ .tooltip-pane[x-placement^="right"] .tooltip-arrow {
147
+ border-width: 8px 8px 8px 0;
148
+ border-left-color: transparent;
149
+ border-top-color: transparent;
150
+ border-bottom-color: transparent;
151
+ top: calc(50% - 8px);
152
+ @apply -tw-left-1 tw-ml-0 tw-mr-0;
153
+ }
154
+
155
+ /* Left Side Tooltip, Arrow appears on Right */
156
+ .tooltip-pane[x-placement^="left"] {
157
+ @apply tw-mr-1;
158
+ }
159
+
160
+ .tooltip-pane[x-placement^="left"] .tooltip-arrow {
161
+ border-width: 8px 0 8px 8px;
162
+ border-top-color: transparent;
163
+ border-right-color: transparent;
164
+ border-bottom-color: transparent;
165
+ top: calc(50% - 8px);
166
+ @apply -tw-right-1 tw-ml-0 tw-mr-0;
167
+ }
168
+ </style>
@@ -0,0 +1,229 @@
1
+ <template>
2
+ <figure class="treemap-figure tw-relative">
3
+ <!-- treemap -->
4
+ <div
5
+ v-for="(block, index) in blocks"
6
+ :key="`block-${index}`"
7
+ class="tw-absolute"
8
+ :style="blockPositionStyles(block)"
9
+ >
10
+ <kv-loading-placeholder
11
+ v-if="loading"
12
+ style="width: calc(100% - 0.25rem); height: calc(100% - 0.25rem);"
13
+ />
14
+ <p
15
+ v-if="!loading"
16
+ class="tw-rounded-sm tw-box-border tw-overflow-hidden tw-text-small"
17
+ :class="colorClasses(index)"
18
+ style="width: calc(100% - 0.25rem); height: calc(100% - 0.25rem);"
19
+ @mouseenter="setHoverBlock(block)"
20
+ >
21
+ <span class="tw-block tw-px-1 tw-py-0.5 tw-truncate">
22
+ {{ block.data.label }} {{ block.data.percent }}
23
+ </span>
24
+ </p>
25
+ </div>
26
+ <!-- tooltip -->
27
+ <div
28
+ ref="tooltipController"
29
+ class="tw-absolute"
30
+ :style="blockPositionStyles(activeBlock)"
31
+ ></div>
32
+ <kv-tooltip
33
+ v-if="!loading && tooltipControllerElement"
34
+ :controller="tooltipControllerElement"
35
+ >
36
+ <template #title>
37
+ {{ activeBlock.data.label }}
38
+ </template>
39
+ {{ activeBlock.data.value }} ({{ activeBlock.data.percent }})
40
+ </kv-tooltip>
41
+ </figure>
42
+ </template>
43
+
44
+ <script>
45
+ import numeral from 'numeral';
46
+ import { getTreemap } from 'treemap-squarify';
47
+ import kvTokensPrimitives from '@kiva/kv-tokens/primitives.json';
48
+ import { throttle } from '../utils/throttle';
49
+ import KvTooltip from './KvTooltip.vue';
50
+ import KvLoadingPlaceholder from './KvLoadingPlaceholder.vue';
51
+
52
+ const { breakpoints } = kvTokensPrimitives;
53
+
54
+ export default {
55
+ name: 'TreeMapFigure',
56
+ components: {
57
+ KvLoadingPlaceholder,
58
+ KvTooltip,
59
+ },
60
+ props: {
61
+ loading: {
62
+ type: Boolean,
63
+ default: true,
64
+ },
65
+ values: {
66
+ type: Array,
67
+ default: () => [],
68
+ },
69
+ },
70
+ data() {
71
+ return {
72
+ screenWidth: 0,
73
+ activeBlock: { data: {} },
74
+ tooltipControllerElement: null,
75
+ };
76
+ },
77
+ computed: {
78
+ activeBreakpoints() {
79
+ return Object.keys(breakpoints)
80
+ .filter((name) => this.screenWidth >= breakpoints[name]);
81
+ },
82
+ // Used to determine if blocks should be displayed in landscape or portrait orientation
83
+ aboveMedium() {
84
+ return this.activeBreakpoints.includes('md');
85
+ },
86
+ // Calculate the blocks of the treemap, but don't apply any formatting
87
+ rawBlocks() {
88
+ // Use static blocks when no data is available (either loading or no loans)
89
+ if (!this.values?.length) {
90
+ return this.loading ? [
91
+ {
92
+ x: 0, y: 0, width: 60, height: 40,
93
+ },
94
+ { x: 60, y: 0, height: 40 },
95
+ { x: 0, y: 40, width: 30 },
96
+ { x: 30, y: 40 },
97
+ ] : [
98
+ { x: 0, y: 0, data: { label: 'No loans yet' } },
99
+ ];
100
+ }
101
+
102
+ // Calculate treemap blocks using canvas size 100x100 to easily translate to percentages
103
+ const blocks = getTreemap({
104
+ data: this.values,
105
+ width: 100,
106
+ height: 100,
107
+ });
108
+
109
+ // Blocks smaller than 8% in either dimension will not display well,
110
+ // so combine them into one large 'Other' block
111
+ // If we only have 1 small block though, don't make the 'Other' block
112
+ const tooSmall = blocks.filter((block) => block.width <= 8 || block.height <= 8);
113
+ if (tooSmall.length === 1) return blocks;
114
+
115
+ const bigBlocks = blocks.filter((block) => block.width > 8 && block.height > 8);
116
+ return [...bigBlocks, this.reduceBlocks(tooSmall)];
117
+ },
118
+ // This is the array that is iterated over in the template
119
+ blocks() {
120
+ return this.rawBlocks.map((block) => {
121
+ const {
122
+ data, x, y, width, height,
123
+ } = block ?? {};
124
+ const { percent } = data ?? {};
125
+
126
+ return {
127
+ data: {
128
+ ...data,
129
+ percent: numeral(percent).format('0[.]0%'),
130
+ },
131
+ // flip x/y when going between small (portrait) and medium (landscape) screens
132
+ x: this.aboveMedium ? x : y,
133
+ y: this.aboveMedium ? y : x,
134
+ width: this.aboveMedium ? width : height,
135
+ height: this.aboveMedium ? height : width,
136
+ };
137
+ });
138
+ },
139
+ },
140
+ mounted() {
141
+ this.screenWidth = window.innerWidth;
142
+ window.addEventListener('resize', throttle(() => {
143
+ this.screenWidth = window.innerWidth;
144
+ }, 200));
145
+
146
+ // Used by KvTooltip/KvPopper to position the tooltip
147
+ if (this.$refs?.tooltipController) {
148
+ this.tooltipControllerElement = this.$refs.tooltipController;
149
+ }
150
+ },
151
+ methods: {
152
+ // Used by the p elements in the main v-for loop to determine background and text color
153
+ colorClasses(index) {
154
+ const classes = [];
155
+ const total = this.blocks?.length ?? 1;
156
+
157
+ // background
158
+ const size = (10 - Math.round((index / total) * 9)) * 100;
159
+ classes.push(size === 600 ? 'tw-bg-brand' : `tw-bg-brand-${size}`);
160
+
161
+ // text
162
+ classes.push(size > 700 ? 'tw-text-white' : 'tw-text-black');
163
+
164
+ return classes;
165
+ },
166
+ // Used by the divs in the main v-for for block positions and the tooltip controller div to match positions
167
+ // with the current active block
168
+ blockPositionStyles(block) {
169
+ return {
170
+ left: `${block.x}%`,
171
+ top: `${block.y}%`,
172
+ width: block.width ? `${block.width}%` : null,
173
+ height: block.height ? `${block.height}%` : null,
174
+ right: block.width ? null : '0',
175
+ bottom: block.height ? null : '0',
176
+ };
177
+ },
178
+ // Combine all small blocks into a single 'Other' block that will be at the bottom right of the figure.
179
+ reduceBlocks(blocks) {
180
+ return blocks?.reduce((other, block) => {
181
+ /* eslint-disable no-param-reassign */
182
+ // Use the smallest x for the overall x
183
+ if (block.x < other.x) {
184
+ other.x = block.x;
185
+ }
186
+ // Use the smallest y for the overall y
187
+ if (block.y < other.y) {
188
+ other.y = block.y;
189
+ }
190
+ // Sum up all values and percents
191
+ other.data.value += block.data.value;
192
+ other.data.percent += block.data.percent;
193
+ return other;
194
+ /* eslint-ensable no-param-reassign */
195
+ }, {
196
+ x: 100,
197
+ y: 100,
198
+ width: 0,
199
+ height: 0,
200
+ data: {
201
+ label: 'Other',
202
+ value: 0,
203
+ percent: 0,
204
+ },
205
+ }) ?? [];
206
+ },
207
+ // Sets the block that will be used to position the tooltip and change the info displayed in the tooltip
208
+ setHoverBlock(block) {
209
+ this.activeBlock = block;
210
+ },
211
+ },
212
+ };
213
+ </script>
214
+
215
+ <style lang="postcss" scoped>
216
+ .treemap-figure {
217
+ height: 30rem;
218
+
219
+ /* account for every block having a 0.25rem right margin */
220
+ width: calc(100% + 0.25rem);
221
+ margin-right: -0.25rem;
222
+ }
223
+
224
+ @screen md {
225
+ .treemap-figure {
226
+ height: 20rem;
227
+ }
228
+ }
229
+ </style>
@@ -402,7 +402,23 @@ export const Dotted = () => ({
402
402
  template: `
403
403
  <kv-carousel
404
404
  style="max-width: 400px;"
405
- is-dotted="true"
405
+ :is-dotted="true"
406
+ >
407
+ ${defaultCarouselSlides}
408
+ </kv-carousel>
409
+ `,
410
+ });
411
+
412
+ export const ThreeDimensional = () => ({
413
+ components: {
414
+ KvCarousel,
415
+ },
416
+ template: `
417
+ <kv-carousel
418
+ :embla-options="{ loop: false, align: 'center' }"
419
+ style="max-width: 600px;"
420
+ :is-dotted="true"
421
+ :in-circle="true"
406
422
  >
407
423
  ${defaultCarouselSlides}
408
424
  </kv-carousel>
@@ -0,0 +1,42 @@
1
+ import KvPieChart from '../KvPieChart.vue';
2
+
3
+ const genderValues = [{ label: 'Female', value: 1243, percent: 0.6467221644120708 }, { label: 'Male', value: 676, percent: 0.3517169614984391 }, { label: 'Unspecified', value: 2, percent: 0.001040582726326743 }, { label: 'Nonbinary', value: 1, percent: 0.0005202913631633715 }];
4
+ const sectorValues = [{ label: 'Food', value: 575, percent: 0.2991675338189386 }, { label: 'Retail', value: 377, percent: 0.19614984391259105 }, { label: 'Agriculture', value: 285, percent: 0.14828303850156088 }, { label: 'Services', value: 211, percent: 0.10978147762747138 }, { label: 'Clothing', value: 183, percent: 0.09521331945889698 }, { label: 'Arts', value: 65, percent: 0.033818938605619145 }, { label: 'Housing', value: 65, percent: 0.033818938605619145 }, { label: 'Education', value: 36, percent: 0.018730489073881373 }, { label: 'Construction', value: 28, percent: 0.014568158168574402 }, { label: 'Health', value: 27, percent: 0.01404786680541103 }, { label: 'Transportation', value: 23, percent: 0.011966701352757543 }, { label: 'Personal Use', value: 19, percent: 0.009885535900104058 }, { label: 'Manufacturing', value: 13, percent: 0.006763787721123829 }, { label: 'Entertainment', value: 10, percent: 0.005202913631633715 }, { label: 'Wholesale', value: 5, percent: 0.0026014568158168575 }];
5
+
6
+ export default {
7
+ title: 'Charts/KvPieChart',
8
+ component: KvPieChart,
9
+ args: {
10
+ loading: false,
11
+ values: genderValues,
12
+ },
13
+ };
14
+
15
+ const Template = (args, {
16
+ argTypes,
17
+ }) => ({
18
+ props: Object.keys(argTypes),
19
+ components: {
20
+ KvPieChart,
21
+ },
22
+ template: `
23
+ <div>
24
+ <kv-pie-chart
25
+ :values="values"
26
+ :loading="loading"
27
+ />
28
+ </div>`,
29
+ });
30
+
31
+ export const Default = Template.bind({});
32
+
33
+ export const Loading = Template.bind({});
34
+ Loading.args = {
35
+ loading: true,
36
+ values: [],
37
+ };
38
+
39
+ export const ManyValues = Template.bind({});
40
+ ManyValues.args = {
41
+ values: sectorValues,
42
+ };
@@ -0,0 +1,26 @@
1
+ import KvTooltip from '../KvTooltip.vue';
2
+
3
+ export default {
4
+ title: 'KvTooltip',
5
+ component: KvTooltip,
6
+ };
7
+
8
+ export const Default = () => ({
9
+ components: {
10
+ KvTooltip,
11
+ },
12
+ template: `
13
+ <div>
14
+ <button id="my-cool-btn">Hover of Focus Me!</button>
15
+ <kv-tooltip controller="my-cool-btn">
16
+ <template #title>
17
+ What is an Experimental Field Partner?
18
+ </template>
19
+ If a Field Partner is labeled as Experimental, this means that Kiva has
20
+ required only a comparatively light level of due diligence and
21
+ monitoring, in exchange for only allowing this Field Partner access to a
22
+ small amount of funding through Kiva at any given time.
23
+ </kv-tooltip>
24
+ </div>
25
+ `,
26
+ });
@@ -0,0 +1,42 @@
1
+ import KvTreeMapChart from '../KvTreeMapChart.vue';
2
+
3
+ const genderValues = [{ label: 'Female', value: 1243, percent: 0.6467221644120708 }, { label: 'Male', value: 676, percent: 0.3517169614984391 }, { label: 'Unspecified', value: 2, percent: 0.001040582726326743 }, { label: 'Nonbinary', value: 1, percent: 0.0005202913631633715 }];
4
+ const sectorValues = [{ label: 'Food', value: 575, percent: 0.2991675338189386 }, { label: 'Retail', value: 377, percent: 0.19614984391259105 }, { label: 'Agriculture', value: 285, percent: 0.14828303850156088 }, { label: 'Services', value: 211, percent: 0.10978147762747138 }, { label: 'Clothing', value: 183, percent: 0.09521331945889698 }, { label: 'Arts', value: 65, percent: 0.033818938605619145 }, { label: 'Housing', value: 65, percent: 0.033818938605619145 }, { label: 'Education', value: 36, percent: 0.018730489073881373 }, { label: 'Construction', value: 28, percent: 0.014568158168574402 }, { label: 'Health', value: 27, percent: 0.01404786680541103 }, { label: 'Transportation', value: 23, percent: 0.011966701352757543 }, { label: 'Personal Use', value: 19, percent: 0.009885535900104058 }, { label: 'Manufacturing', value: 13, percent: 0.006763787721123829 }, { label: 'Entertainment', value: 10, percent: 0.005202913631633715 }, { label: 'Wholesale', value: 5, percent: 0.0026014568158168575 }];
5
+
6
+ export default {
7
+ title: 'Charts/KvTreeMapChart',
8
+ component: KvTreeMapChart,
9
+ args: {
10
+ loading: false,
11
+ values: genderValues,
12
+ },
13
+ };
14
+
15
+ const Template = (args, {
16
+ argTypes,
17
+ }) => ({
18
+ props: Object.keys(argTypes),
19
+ components: {
20
+ KvTreeMapChart,
21
+ },
22
+ template: `
23
+ <div>
24
+ <kv-tree-map-chart
25
+ :values="values"
26
+ :loading="loading"
27
+ />
28
+ </div>`,
29
+ });
30
+
31
+ export const Default = Template.bind({});
32
+
33
+ export const Loading = Template.bind({});
34
+ Loading.args = {
35
+ loading: true,
36
+ values: [],
37
+ };
38
+
39
+ export const ManyValues = Template.bind({});
40
+ ManyValues.args = {
41
+ values: sectorValues,
42
+ };