@kiva/kv-components 3.93.0 → 3.95.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.
@@ -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>
@@ -457,3 +457,24 @@ export const ContributorsAndAmount = story({
457
457
  photoPath,
458
458
  showContributors: true,
459
459
  });
460
+
461
+ export const USLoan = story({
462
+ loanId: loan.id,
463
+ loan: {
464
+ ...loan,
465
+ geocode: {
466
+ city: 'Kittanning',
467
+ state: 'PA',
468
+ country: {
469
+ isoCode: 'US',
470
+ name: 'United States',
471
+ region: 'North America',
472
+ __typename: 'Country',
473
+ },
474
+ __typename: 'Geocode',
475
+ },
476
+ distributionModel: 'direct',
477
+ },
478
+ kvTrackFunction,
479
+ photoPath,
480
+ });
@@ -84,14 +84,7 @@ const photoPath = 'https://www-kiva-org.freetls.fastly.net/img/';
84
84
 
85
85
  export const Default = story({
86
86
  loanId: loan.id,
87
- loan: {
88
- ...loan,
89
- loanFundraisingInfo: {
90
- fundedAmount: '200.00',
91
- isExpiringSoon: false,
92
- reservedAmount: '0.00',
93
- },
94
- },
87
+ loan,
95
88
  kvTrackFunction,
96
89
  photoPath,
97
90
  });
@@ -120,13 +113,87 @@ export const Matched = story({
120
113
  ...loan,
121
114
  matchingText: 'Ebay',
122
115
  matchRatio: 1,
116
+ },
117
+ kvTrackFunction,
118
+ photoPath,
119
+ });
120
+
121
+ export const LseLoan = story({
122
+ loanId: loan.id,
123
+ loan: {
124
+ ...loan,
125
+ partnerName: 'N/A, direct to Novulis',
126
+ },
127
+ kvTrackFunction,
128
+ photoPath,
129
+ });
130
+
131
+ export const AlmostFunded = story({
132
+ loanId: loan.id,
133
+ loan: {
134
+ ...loan,
135
+ loanAmount: '100.00',
136
+ unreservedAmount: '10.00',
137
+ fundraisingPercent: 0.9,
123
138
  loanFundraisingInfo: {
124
- fundedAmount: '200.00',
125
- isExpiringSoon: false,
139
+ fundedAmount: '90.00',
126
140
  reservedAmount: '0.00',
127
141
  },
128
142
  },
129
143
  kvTrackFunction,
130
144
  photoPath,
131
- loanCallouts: [{ label: 'callout 1' }, { label: 'callout 2' }, { label: 'callout 3' }],
145
+ });
146
+
147
+ const tomorrow = new Date();
148
+ tomorrow.setDate(new Date().getDate() + 1);
149
+
150
+ export const ExpiringSoon = story({
151
+ loanId: loan.id,
152
+ loan: {
153
+ ...loan,
154
+ plannedExpirationDate: tomorrow.toISOString(),
155
+ },
156
+ kvTrackFunction,
157
+ photoPath,
158
+ });
159
+
160
+ export const Funded = story({
161
+ loanId: loan.id,
162
+ loan: {
163
+ ...loan,
164
+ unreservedAmount: '0.00',
165
+ },
166
+ kvTrackFunction,
167
+ photoPath,
168
+ });
169
+
170
+ export const LongName = story({
171
+ loanId: loan.id,
172
+ loan: {
173
+ ...loan,
174
+ name: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
175
+ },
176
+ kvTrackFunction,
177
+ photoPath,
178
+ });
179
+
180
+ export const USLoan = story({
181
+ loanId: loan.id,
182
+ loan: {
183
+ ...loan,
184
+ geocode: {
185
+ city: 'Kittanning',
186
+ state: 'PA',
187
+ country: {
188
+ isoCode: 'US',
189
+ name: 'United States',
190
+ region: 'North America',
191
+ __typename: 'Country',
192
+ },
193
+ __typename: 'Geocode',
194
+ },
195
+ distributionModel: 'direct',
196
+ },
197
+ kvTrackFunction,
198
+ photoPath,
132
199
  });