@citizenplane/pimp 18.9.36 → 18.10.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/dist/components/CpTabs.vue.d.ts +6 -1
- package/dist/components/CpTabs.vue.d.ts.map +1 -1
- package/dist/pimp.es.js +641 -581
- package/dist/pimp.umd.js +2 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/CpTabs.vue +234 -48
- package/src/stories/CpTabs.stories.ts +23 -10
package/package.json
CHANGED
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div ref="cpTabsElement" class="cpTabs" role="tablist">
|
|
2
|
+
<div ref="cpTabsElement" class="cpTabs" :class="dynamicTabsClass" role="tablist">
|
|
3
3
|
<button
|
|
4
4
|
v-for="(tab, index) in tabs"
|
|
5
5
|
:key="tab.title"
|
|
6
6
|
class="cpTabs__tab"
|
|
7
7
|
:class="getTabClass(index)"
|
|
8
|
+
:aria-selected="isActiveTab(index)"
|
|
8
9
|
role="tab"
|
|
9
|
-
tabindex="
|
|
10
|
+
:tabindex="getTabIndex(index)"
|
|
10
11
|
type="button"
|
|
11
12
|
@click="handleTabClick(index)"
|
|
13
|
+
@keydown="handleTabKeydown($event, index)"
|
|
12
14
|
>
|
|
13
|
-
<cp-icon v-if="tab.
|
|
14
|
-
<cp-
|
|
15
|
+
<cp-icon v-if="tab.leadingIcon" class="cpTabs__icon" :class="dynamicIconClass" :type="tab.leadingIcon" />
|
|
16
|
+
<cp-text class="cpTabs__label" tag="span" :size="textSize">
|
|
15
17
|
{{ tab.title }}
|
|
16
|
-
</cp-
|
|
18
|
+
</cp-text>
|
|
17
19
|
<div v-if="hasTabCount(tab.count)" class="cpTabs__count">
|
|
18
|
-
<cp-badge :color="dynamicBadgeColor(index)" size="
|
|
20
|
+
<cp-badge :color="dynamicBadgeColor(index)" :variant="dynamicBadgeVariant(index)" :size="badgeSize">
|
|
19
21
|
{{ tab.count }}
|
|
20
22
|
</cp-badge>
|
|
21
23
|
</div>
|
|
24
|
+
<cp-icon v-if="tab.trailingIcon" class="cpTabs__icon" :class="dynamicIconClass" :type="tab.trailingIcon" />
|
|
22
25
|
</button>
|
|
23
|
-
<div ref="activeUnderline" class="cpTabs__activeUnderline" />
|
|
26
|
+
<div ref="activeUnderline" class="cpTabs__activeUnderline" :class="dynamicUnderlineClass" />
|
|
24
27
|
</div>
|
|
25
28
|
</template>
|
|
26
29
|
|
|
27
30
|
<script setup lang="ts">
|
|
28
|
-
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
31
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
32
|
+
|
|
33
|
+
import CpText from '@/components/CpText.vue'
|
|
34
|
+
|
|
35
|
+
import { capitalizeFirstLetter } from '@/helpers'
|
|
29
36
|
|
|
30
37
|
type EmitType = {
|
|
31
38
|
(e: 'onTabClick', index: number): void
|
|
@@ -34,38 +41,130 @@ type EmitType = {
|
|
|
34
41
|
interface Props {
|
|
35
42
|
defaultActiveIndex?: number
|
|
36
43
|
isLoading?: boolean
|
|
44
|
+
size?: 'xs' | 'sm' | 'md' | 'lg'
|
|
37
45
|
tabs: {
|
|
38
46
|
count?: number
|
|
39
|
-
|
|
47
|
+
leadingIcon?: string
|
|
40
48
|
title: string
|
|
49
|
+
trailingIcon?: string
|
|
41
50
|
}[]
|
|
51
|
+
variant?: 'brand' | 'underline'
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
54
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
55
|
+
defaultActiveIndex: 0,
|
|
56
|
+
size: 'md',
|
|
57
|
+
variant: 'underline'
|
|
58
|
+
})
|
|
45
59
|
|
|
46
60
|
const emit = defineEmits<EmitType>()
|
|
47
61
|
|
|
48
62
|
const cpTabsElement = ref<HTMLElement | null>(null)
|
|
49
63
|
const activeUnderline = ref<HTMLElement | null>(null)
|
|
50
64
|
const activeTabIndex = ref<number | null>(props.defaultActiveIndex)
|
|
65
|
+
const suppressUnderlineTransition = ref(false)
|
|
66
|
+
|
|
67
|
+
const textSize = computed(() => {
|
|
68
|
+
switch (props.size) {
|
|
69
|
+
case 'xs':
|
|
70
|
+
case 'sm':
|
|
71
|
+
return 'xs'
|
|
72
|
+
case 'md':
|
|
73
|
+
case 'lg':
|
|
74
|
+
default:
|
|
75
|
+
return 'md'
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const badgeSize = computed(() => {
|
|
80
|
+
switch (props.size) {
|
|
81
|
+
case 'xs':
|
|
82
|
+
case 'sm':
|
|
83
|
+
return '2xs'
|
|
84
|
+
case 'md':
|
|
85
|
+
case 'lg':
|
|
86
|
+
default:
|
|
87
|
+
return 'xs'
|
|
88
|
+
}
|
|
89
|
+
})
|
|
51
90
|
|
|
52
91
|
const hasTabCount = (count?: number) => typeof count === 'number'
|
|
53
92
|
|
|
54
|
-
const
|
|
93
|
+
const isActiveTab = (index: number) => activeTabIndex.value === index
|
|
94
|
+
|
|
95
|
+
const getTabIndex = (index: number) => isActiveTab(index) ? 0 : -1
|
|
96
|
+
|
|
97
|
+
const activateTab = async (index: number, { focus = false } = {}) => {
|
|
55
98
|
if (props.isLoading) return
|
|
99
|
+
if (index < 0 || index >= props.tabs.length) return
|
|
100
|
+
|
|
101
|
+
if (focus) suppressUnderlineTransition.value = true
|
|
56
102
|
|
|
57
103
|
activeTabIndex.value = index
|
|
58
104
|
emit('onTabClick', index)
|
|
59
|
-
scrollToActiveElement()
|
|
105
|
+
scrollToActiveElement(focus ? 'instant' : 'smooth')
|
|
106
|
+
|
|
107
|
+
if (!focus) return
|
|
108
|
+
|
|
109
|
+
await nextTick()
|
|
110
|
+
getActiveTabElement()?.focus()
|
|
111
|
+
requestAnimationFrame(() => (suppressUnderlineTransition.value = false))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const handleTabClick = (index: number) => activateTab(index)
|
|
115
|
+
|
|
116
|
+
const handleTabKeydown = (event: KeyboardEvent, index: number) => {
|
|
117
|
+
if (props.isLoading) return
|
|
118
|
+
|
|
119
|
+
const lastIndex = props.tabs.length - 1
|
|
120
|
+
let nextIndex: number | null = null
|
|
121
|
+
|
|
122
|
+
switch (event.key) {
|
|
123
|
+
case 'ArrowRight':
|
|
124
|
+
nextIndex = index === lastIndex ? 0 : index + 1
|
|
125
|
+
break
|
|
126
|
+
case 'ArrowLeft':
|
|
127
|
+
nextIndex = index === 0 ? lastIndex : index - 1
|
|
128
|
+
break
|
|
129
|
+
default:
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
activateTab(nextIndex, { focus: true })
|
|
60
135
|
}
|
|
61
136
|
|
|
137
|
+
const dynamicIconClass = computed(() => {
|
|
138
|
+
return { [`cpTabs__icon--${props.size}`]: true }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const dynamicTabsClass = computed(() => {
|
|
142
|
+
return {
|
|
143
|
+
[`cpTabs--${props.size}`]: true,
|
|
144
|
+
[`cpTabs--is${capitalizeFirstLetter(props.variant)}`]: true,
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
62
148
|
const getTabClass = (tabIndex: number) => {
|
|
63
149
|
if (!props.tabs[tabIndex]) return
|
|
64
150
|
|
|
65
|
-
return
|
|
151
|
+
return {
|
|
152
|
+
'cpTabs__tab--isActive': activeTabIndex.value === tabIndex,
|
|
153
|
+
'cpTabs__tab--isLoading': props.isLoading,
|
|
154
|
+
[`cpTabs__tab--${props.size}`]: true,
|
|
155
|
+
}
|
|
66
156
|
}
|
|
67
157
|
|
|
68
|
-
const
|
|
158
|
+
const dynamicUnderlineClass = computed(() => {
|
|
159
|
+
return {
|
|
160
|
+
[`cpTabs__activeUnderline--is${capitalizeFirstLetter(props.variant)}`]: true,
|
|
161
|
+
'cpTabs__activeUnderline--noTransition': suppressUnderlineTransition.value
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const dynamicBadgeColor = (index: number) => index === activeTabIndex.value ? 'accent' : 'neutral'
|
|
166
|
+
|
|
167
|
+
const dynamicBadgeVariant = (index: number) => index === activeTabIndex.value ? 'solid' : 'soft'
|
|
69
168
|
|
|
70
169
|
const getActiveTabElement = () => {
|
|
71
170
|
const componentElement = cpTabsElement.value
|
|
@@ -83,6 +182,17 @@ const getUnderlineElement = () => {
|
|
|
83
182
|
return underlineElement
|
|
84
183
|
}
|
|
85
184
|
|
|
185
|
+
// NOTE: fix mimatch width between active underline & focus outline of active tab
|
|
186
|
+
const getUnderlineInset = () => {
|
|
187
|
+
if (props.variant !== 'underline') return 0
|
|
188
|
+
|
|
189
|
+
const rootStyle = getComputedStyle(document.documentElement)
|
|
190
|
+
const rootFontSize = Number.parseFloat(rootStyle.fontSize)
|
|
191
|
+
const insetRem = Number.parseFloat(rootStyle.getPropertyValue('--cp-dimensions-0_5'))
|
|
192
|
+
|
|
193
|
+
return rootFontSize * insetRem
|
|
194
|
+
}
|
|
195
|
+
|
|
86
196
|
const setUnderlineStyle = () => {
|
|
87
197
|
const activeTabElement = getActiveTabElement()
|
|
88
198
|
const underlineElement = getUnderlineElement()
|
|
@@ -90,15 +200,17 @@ const setUnderlineStyle = () => {
|
|
|
90
200
|
|
|
91
201
|
if (!activeTabElement || !underlineElement || !containerElement) return
|
|
92
202
|
|
|
93
|
-
|
|
94
|
-
|
|
203
|
+
const inset = getUnderlineInset()
|
|
204
|
+
|
|
205
|
+
underlineElement.style.width = `${activeTabElement.offsetWidth - inset * 2}px`
|
|
206
|
+
underlineElement.style.transform = `translate3d(${activeTabElement.offsetLeft + inset}px, 0, 0)`
|
|
95
207
|
}
|
|
96
208
|
|
|
97
|
-
const scrollToActiveElement = () => {
|
|
209
|
+
const scrollToActiveElement = (behavior: ScrollBehavior = 'smooth') => {
|
|
98
210
|
const activeTabElement = getActiveTabElement()
|
|
99
211
|
if (!activeTabElement) return
|
|
100
212
|
|
|
101
|
-
return activeTabElement.scrollIntoView({ block: 'nearest', behavior
|
|
213
|
+
return activeTabElement.scrollIntoView({ block: 'nearest', behavior })
|
|
102
214
|
}
|
|
103
215
|
|
|
104
216
|
onBeforeUnmount(() => window.removeEventListener('resize', setUnderlineStyle))
|
|
@@ -123,29 +235,61 @@ watch(
|
|
|
123
235
|
position: relative;
|
|
124
236
|
display: flex;
|
|
125
237
|
align-items: center;
|
|
126
|
-
|
|
238
|
+
|
|
239
|
+
&--xs {
|
|
240
|
+
gap: var(--cp-spacing-xs);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
&--sm {
|
|
244
|
+
gap: var(--cp-spacing-sm);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
&--md {
|
|
248
|
+
gap: var(--cp-spacing-lg);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
&--lg {
|
|
252
|
+
gap: var(--cp-spacing-lg);
|
|
253
|
+
}
|
|
127
254
|
|
|
128
255
|
&__activeUnderline {
|
|
129
256
|
position: absolute;
|
|
130
|
-
|
|
131
|
-
left: 0;
|
|
132
|
-
height: var(--cp-dimensions-0_5);
|
|
257
|
+
z-index: 0;
|
|
133
258
|
transition:
|
|
134
259
|
background-color 300ms ease,
|
|
135
260
|
transform 300ms cubic-bezier(0.34, 1.26, 0.64, 1),
|
|
136
261
|
width 300ms cubic-bezier(0.34, 1.26, 0.64, 1);
|
|
262
|
+
|
|
263
|
+
&--noTransition {
|
|
264
|
+
transition: none;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
&--isBrand {
|
|
268
|
+
top: auto;
|
|
269
|
+
bottom: auto;
|
|
270
|
+
height: 100%;
|
|
271
|
+
border-radius: var(--cp-radius-md);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
&--isUnderline {
|
|
275
|
+
bottom: 0;
|
|
276
|
+
left: 0;
|
|
277
|
+
height: var(--cp-dimensions-0_5);
|
|
278
|
+
margin-bottom: -1px;
|
|
279
|
+
}
|
|
137
280
|
}
|
|
138
281
|
|
|
139
282
|
&__tab {
|
|
140
283
|
@extend %u-focus-outline;
|
|
141
284
|
|
|
142
285
|
position: relative;
|
|
286
|
+
z-index: 1;
|
|
143
287
|
display: flex;
|
|
288
|
+
flex: 1;
|
|
144
289
|
align-items: center;
|
|
145
|
-
|
|
290
|
+
justify-content: center;
|
|
146
291
|
border-radius: var(--cp-radius-md);
|
|
147
292
|
color: var(--cp-text-secondary);
|
|
148
|
-
gap: var(--cp-spacing-sm);
|
|
149
293
|
-webkit-tap-highlight-color: transparent;
|
|
150
294
|
transition:
|
|
151
295
|
box-shadow 300ms ease,
|
|
@@ -164,20 +308,73 @@ watch(
|
|
|
164
308
|
color: var(--cp-text-accent-primary);
|
|
165
309
|
}
|
|
166
310
|
|
|
167
|
-
&--isActive ~ .cpTabs__activeUnderline {
|
|
168
|
-
background-color: var(--cp-
|
|
311
|
+
&--isActive ~ .cpTabs__activeUnderline--isBrand {
|
|
312
|
+
background-color: var(--cp-background-accent-secondary);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
&--isActive ~ .cpTabs__activeUnderline--isUnderline {
|
|
316
|
+
background-color: var(--cp-background-accent-solid);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
&--xs {
|
|
320
|
+
padding: var(--cp-spacing-sm-md);
|
|
321
|
+
gap: var(--cp-spacing-xs);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
&--sm {
|
|
325
|
+
padding: var(--cp-spacing-md);
|
|
326
|
+
gap: var(--cp-spacing-sm);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
&--md {
|
|
330
|
+
padding: var(--cp-spacing-md) var(--cp-spacing-lg);
|
|
331
|
+
gap: var(--cp-spacing-sm-md);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
&--lg {
|
|
335
|
+
padding: var(--cp-spacing-lg) var(--cp-spacing-xl);
|
|
336
|
+
gap: var(--cp-spacing-sm-md);
|
|
169
337
|
}
|
|
170
338
|
}
|
|
171
339
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
340
|
+
&--isUnderline {
|
|
341
|
+
border-bottom: 1px solid var(--cp-border-soft);
|
|
342
|
+
|
|
343
|
+
.cpTabs__tab {
|
|
344
|
+
margin-bottom: -1px;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.cpTabs__tab:focus-visible {
|
|
348
|
+
border-bottom-left-radius: 0;
|
|
349
|
+
border-bottom-right-radius: 0;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
&__label {
|
|
177
354
|
transition: color 300ms ease;
|
|
178
355
|
white-space: nowrap;
|
|
179
356
|
}
|
|
180
357
|
|
|
358
|
+
&__label--xs {
|
|
359
|
+
font-size: var(--cp-text-size-xs);
|
|
360
|
+
line-height: var(--cp-line-height-xs);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
&__label--sm {
|
|
364
|
+
font-size: var(--cp-text-size-sm);
|
|
365
|
+
line-height: var(--cp-line-height-sm);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
&__label--md {
|
|
369
|
+
font-size: var(--cp-text-size-md);
|
|
370
|
+
line-height: var(--cp-line-height-md);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
&__label--lg {
|
|
374
|
+
font-size: var(--cp-text-size-lg);
|
|
375
|
+
line-height: var(--cp-line-height-lg);
|
|
376
|
+
}
|
|
377
|
+
|
|
181
378
|
&__count {
|
|
182
379
|
font-variant: tabular-nums;
|
|
183
380
|
}
|
|
@@ -186,26 +383,15 @@ watch(
|
|
|
186
383
|
position: relative;
|
|
187
384
|
display: flex;
|
|
188
385
|
border-radius: var(--cp-radius-sm);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
386
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
border-bottom-left-radius: 0;
|
|
196
|
-
border-bottom-right-radius: 0;
|
|
387
|
+
&--xs,
|
|
388
|
+
&--sm {
|
|
389
|
+
@include mx.square-sizing(16);
|
|
197
390
|
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
391
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
top: auto;
|
|
205
|
-
bottom: auto;
|
|
206
|
-
height: 100%;
|
|
207
|
-
border-radius: var(--cp-radius-md);
|
|
208
|
-
opacity: 0.14;
|
|
392
|
+
&--md,
|
|
393
|
+
&--lg {
|
|
394
|
+
@include mx.square-sizing(20);
|
|
209
395
|
}
|
|
210
396
|
}
|
|
211
397
|
}
|
|
@@ -20,6 +20,16 @@ const meta = {
|
|
|
20
20
|
control: 'object',
|
|
21
21
|
description: 'Array of tab objects with title, optional icon, and optional count',
|
|
22
22
|
},
|
|
23
|
+
size: {
|
|
24
|
+
control: 'select',
|
|
25
|
+
options: ['xs', 'sm', 'md', 'lg'],
|
|
26
|
+
description: 'The size of the tabs',
|
|
27
|
+
},
|
|
28
|
+
variant: {
|
|
29
|
+
control: 'select',
|
|
30
|
+
options: ['brand', 'underline'],
|
|
31
|
+
description: 'The variant of the tabs',
|
|
32
|
+
},
|
|
23
33
|
},
|
|
24
34
|
} satisfies Meta<typeof CpTabs>
|
|
25
35
|
|
|
@@ -32,7 +42,10 @@ const defaultRender = (args: Args) => ({
|
|
|
32
42
|
setup() {
|
|
33
43
|
return { args }
|
|
34
44
|
},
|
|
35
|
-
template:
|
|
45
|
+
template: `
|
|
46
|
+
<CpTabs v-bind="args" @on-tab-click="args.onOnTabClick" variant="underline" /><br />
|
|
47
|
+
<CpTabs v-bind="args" @on-tab-click="args.onOnTabClick" variant="brand" />
|
|
48
|
+
`,
|
|
36
49
|
})
|
|
37
50
|
|
|
38
51
|
/**
|
|
@@ -57,9 +70,9 @@ export const WithIcons: Story = {
|
|
|
57
70
|
args: {
|
|
58
71
|
...Default.args,
|
|
59
72
|
tabs: [
|
|
60
|
-
{ title: 'Home',
|
|
61
|
-
{ title: 'Settings',
|
|
62
|
-
{ title: 'User',
|
|
73
|
+
{ title: 'Home', leadingIcon: 'circle', trailingIcon: 'circle' },
|
|
74
|
+
{ title: 'Settings', leadingIcon: 'circle', trailingIcon: 'circle' },
|
|
75
|
+
{ title: 'User', leadingIcon: 'circle', trailingIcon: 'circle' },
|
|
63
76
|
],
|
|
64
77
|
},
|
|
65
78
|
render: defaultRender,
|
|
@@ -88,9 +101,9 @@ export const WithIconsAndCounts: Story = {
|
|
|
88
101
|
args: {
|
|
89
102
|
...Default.args,
|
|
90
103
|
tabs: [
|
|
91
|
-
{ title: 'Notifications',
|
|
92
|
-
{ title: 'Messages',
|
|
93
|
-
{ title: 'Tasks',
|
|
104
|
+
{ title: 'Notifications', leadingIcon: 'circle', trailingIcon: 'circle', count: 8 },
|
|
105
|
+
{ title: 'Messages', leadingIcon: 'circle', trailingIcon: 'circle', count: 3 },
|
|
106
|
+
{ title: 'Tasks', leadingIcon: 'circle', trailingIcon: 'circle', count: 15 },
|
|
94
107
|
],
|
|
95
108
|
},
|
|
96
109
|
render: defaultRender,
|
|
@@ -105,9 +118,9 @@ export const InDialog: Story = {
|
|
|
105
118
|
...Default.args,
|
|
106
119
|
defaultActiveIndex: 1,
|
|
107
120
|
tabs: [
|
|
108
|
-
{ title: 'Notifications',
|
|
109
|
-
{ title: 'Messages',
|
|
110
|
-
{ title: 'Tasks',
|
|
121
|
+
{ title: 'Notifications', leadingIcon: 'circle', trailingIcon: 'circle', count: 8 },
|
|
122
|
+
{ title: 'Messages', leadingIcon: 'circle', trailingIcon: 'circle', count: 3 },
|
|
123
|
+
{ title: 'Tasks', leadingIcon: 'circle', trailingIcon: 'circle', count: 15 },
|
|
111
124
|
],
|
|
112
125
|
},
|
|
113
126
|
render: (args) => ({
|