@citizenplane/pimp 9.15.4 → 9.16.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/pimp.es.js +1558 -1466
- package/dist/pimp.umd.js +30 -30
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/CpTable.vue +1 -1
- package/src/components/CpTabs.vue +238 -0
- package/src/components/index.ts +2 -0
- package/src/stories/CpTabs.stories.ts +83 -0
package/package.json
CHANGED
|
@@ -267,7 +267,7 @@ const getVisibleColumnsIds = () => {
|
|
|
267
267
|
const fullWidthColumn = computed(() => normalizedColumns.value.find(({ isFull }) => isFull))
|
|
268
268
|
|
|
269
269
|
const hasAFullWidthColumnVisible = computed(() => {
|
|
270
|
-
if (!fullWidthColumn.value) return
|
|
270
|
+
if (!fullWidthColumn.value) return true
|
|
271
271
|
return columnsModel.value.includes(fullWidthColumn.value.id)
|
|
272
272
|
})
|
|
273
273
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="cpTabsElement" class="cpTabs" role="tablist">
|
|
3
|
+
<button
|
|
4
|
+
v-for="(tab, index) in tabs"
|
|
5
|
+
:key="tab.title"
|
|
6
|
+
class="cpTabs__tab"
|
|
7
|
+
:class="getTabClass(index)"
|
|
8
|
+
role="tab"
|
|
9
|
+
tabindex="0"
|
|
10
|
+
type="button"
|
|
11
|
+
@click="handleTabClick(index)"
|
|
12
|
+
>
|
|
13
|
+
<cp-icon v-if="tab.icon" class="cpTabs__icon" size="16" :type="tab.icon" />
|
|
14
|
+
<cp-heading class="cpTabs__title" heading-level="h4">
|
|
15
|
+
{{ tab.title }}
|
|
16
|
+
</cp-heading>
|
|
17
|
+
<div v-if="hasTabCount(tab.count)" class="cpTabs__count">
|
|
18
|
+
<cp-badge :color="dynamicBadgeColor(index)" size="xs">
|
|
19
|
+
{{ tab.count }}
|
|
20
|
+
</cp-badge>
|
|
21
|
+
</div>
|
|
22
|
+
</button>
|
|
23
|
+
<div ref="activeUnderline" class="cpTabs__activeUnderline" />
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup lang="ts">
|
|
28
|
+
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
29
|
+
|
|
30
|
+
type EmitType = {
|
|
31
|
+
(e: 'onTabClick', index: number): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
defaultActiveIndex?: number
|
|
36
|
+
isLoading?: boolean
|
|
37
|
+
tabs: {
|
|
38
|
+
count?: number
|
|
39
|
+
icon?: string
|
|
40
|
+
title: string
|
|
41
|
+
}[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
45
|
+
defaultActiveIndex: 0,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const emit = defineEmits<EmitType>()
|
|
49
|
+
|
|
50
|
+
const cpTabsElement = ref<HTMLElement | null>(null)
|
|
51
|
+
const activeUnderline = ref<HTMLElement | null>(null)
|
|
52
|
+
const activeTabIndex = ref<number | null>(null)
|
|
53
|
+
|
|
54
|
+
const hasTabCount = (count?: number) => typeof count === 'number'
|
|
55
|
+
|
|
56
|
+
const handleTabClick = (index: number) => {
|
|
57
|
+
if (props.isLoading) return
|
|
58
|
+
|
|
59
|
+
activeTabIndex.value = index
|
|
60
|
+
emit('onTabClick', index)
|
|
61
|
+
scrollToActiveElement()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const getTabClass = (tabIndex: number) => {
|
|
65
|
+
if (!props.tabs[tabIndex]) return
|
|
66
|
+
|
|
67
|
+
return [{ 'cpTabs__tab--isActive': activeTabIndex.value === tabIndex }, { 'cpTabs__tab--isLoading': props.isLoading }]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dynamicBadgeColor = (index: number) => (index === activeTabIndex.value ? 'purple' : 'gray')
|
|
71
|
+
|
|
72
|
+
const getActiveTabElement = () => {
|
|
73
|
+
const componentElement = cpTabsElement.value
|
|
74
|
+
|
|
75
|
+
if (!componentElement) return
|
|
76
|
+
if (activeTabIndex.value === null) return
|
|
77
|
+
|
|
78
|
+
return componentElement.children[activeTabIndex.value] as HTMLElement
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const getUnderlineElement = () => {
|
|
82
|
+
const underlineElement = activeUnderline.value
|
|
83
|
+
if (!underlineElement) return
|
|
84
|
+
|
|
85
|
+
return underlineElement
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const setUnderlineStyle = () => {
|
|
89
|
+
const activeTabElement = getActiveTabElement()
|
|
90
|
+
const underlineElement = getUnderlineElement()
|
|
91
|
+
|
|
92
|
+
if (!activeTabElement) return
|
|
93
|
+
|
|
94
|
+
const { clientWidth, offsetLeft } = activeTabElement
|
|
95
|
+
|
|
96
|
+
if (!underlineElement) return
|
|
97
|
+
|
|
98
|
+
underlineElement.style.width = `${clientWidth}px`
|
|
99
|
+
underlineElement.style.transform = `translate3d(${offsetLeft}px, 0, 0)`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const scrollToActiveElement = () => {
|
|
103
|
+
const activeTabElement = getActiveTabElement()
|
|
104
|
+
if (!activeTabElement) return
|
|
105
|
+
|
|
106
|
+
return activeTabElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onBeforeUnmount(() => window.removeEventListener('resize', setUnderlineStyle))
|
|
110
|
+
|
|
111
|
+
onMounted(() => {
|
|
112
|
+
window.addEventListener('resize', setUnderlineStyle)
|
|
113
|
+
|
|
114
|
+
setUnderlineStyle()
|
|
115
|
+
activeTabIndex.value = props.defaultActiveIndex
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
watch(activeTabIndex, () => setUnderlineStyle())
|
|
119
|
+
|
|
120
|
+
watch(
|
|
121
|
+
() => props.defaultActiveIndex,
|
|
122
|
+
(newValue) => (activeTabIndex.value = newValue),
|
|
123
|
+
)
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<style lang="scss">
|
|
127
|
+
.cpTabs {
|
|
128
|
+
position: relative;
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
|
|
132
|
+
/* Scrolling Visual Cue */
|
|
133
|
+
background:
|
|
134
|
+
linear-gradient(to right, fn.v(background-primary) 30%, rgba(255, 255, 255, 0%)),
|
|
135
|
+
linear-gradient(to right, rgba(fn.v(background-primary), 0), fn.v(background-primary) 70%) 0 100%,
|
|
136
|
+
radial-gradient(
|
|
137
|
+
farthest-side at 0% 50%,
|
|
138
|
+
rgba(fn.v(foreground-secondary), 0.2),
|
|
139
|
+
rgba(fn.v(foreground-secondary), 0)
|
|
140
|
+
),
|
|
141
|
+
radial-gradient(
|
|
142
|
+
farthest-side at 100% 50%,
|
|
143
|
+
rgba(fn.v(foreground-secondary), 0.2),
|
|
144
|
+
rgba(fn.v(foreground-secondary), 0)
|
|
145
|
+
)
|
|
146
|
+
0 100%;
|
|
147
|
+
background-attachment: local, local, scroll, scroll;
|
|
148
|
+
background-color: fn.v(background-primary);
|
|
149
|
+
background-position:
|
|
150
|
+
0 0,
|
|
151
|
+
100%,
|
|
152
|
+
0 0,
|
|
153
|
+
100%;
|
|
154
|
+
background-repeat: no-repeat;
|
|
155
|
+
background-size:
|
|
156
|
+
40px 100%,
|
|
157
|
+
40px 100%,
|
|
158
|
+
14px 100%,
|
|
159
|
+
14px 100%;
|
|
160
|
+
gap: fn.v(spacing-md);
|
|
161
|
+
|
|
162
|
+
&__activeUnderline {
|
|
163
|
+
position: absolute;
|
|
164
|
+
bottom: 0;
|
|
165
|
+
left: 0;
|
|
166
|
+
height: fn.px-to-rem(2);
|
|
167
|
+
transition:
|
|
168
|
+
background-color 300ms ease,
|
|
169
|
+
transform 300ms cubic-bezier(0.34, 1.26, 0.64, 1),
|
|
170
|
+
width 300ms cubic-bezier(0.34, 1.26, 0.64, 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
&__tab {
|
|
174
|
+
@extend %u-focus-outline;
|
|
175
|
+
|
|
176
|
+
position: relative;
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
padding: fn.v(spacing-md);
|
|
180
|
+
border-radius: fn.px-to-rem(8);
|
|
181
|
+
color: fn.v(text-secondary);
|
|
182
|
+
gap: fn.v(spacing-sm);
|
|
183
|
+
-webkit-tap-highlight-color: transparent;
|
|
184
|
+
transition:
|
|
185
|
+
box-shadow 300ms ease,
|
|
186
|
+
opacity 300ms ease;
|
|
187
|
+
|
|
188
|
+
&:focus-visible {
|
|
189
|
+
outline-offset: fn.px-to-rem(-2);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
&--isLoading {
|
|
193
|
+
opacity: 0.5;
|
|
194
|
+
pointer-events: none;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
&--isActive {
|
|
198
|
+
color: fn.v(text-accent-primary);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
&--isActive ~ .cpTabs__activeUnderline {
|
|
202
|
+
background-color: fn.v(border-accent-solid);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
&__title {
|
|
207
|
+
font-size: fn.px-to-em(14);
|
|
208
|
+
font-weight: 500;
|
|
209
|
+
line-height: fn.px-to-rem(24);
|
|
210
|
+
padding-inline: fn.v(spacing-sm);
|
|
211
|
+
transition: color 300ms ease;
|
|
212
|
+
white-space: nowrap;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
&__count {
|
|
216
|
+
font-variant: tabular-nums;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
&__icon {
|
|
220
|
+
position: relative;
|
|
221
|
+
display: flex;
|
|
222
|
+
border-radius: fn.px-to-rem(4);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@media (max-width: 700px) {
|
|
226
|
+
margin-right: fn.v(spacing-lg);
|
|
227
|
+
overflow-x: auto;
|
|
228
|
+
|
|
229
|
+
&__activeUnderline {
|
|
230
|
+
top: auto;
|
|
231
|
+
bottom: auto;
|
|
232
|
+
height: 100%;
|
|
233
|
+
border-radius: fn.px-to-rem(8);
|
|
234
|
+
opacity: 0.14;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
</style>
|
package/src/components/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import CpSelectMenu from './CpSelectMenu.vue'
|
|
|
39
39
|
import CpSwitch from './CpSwitch.vue'
|
|
40
40
|
import CpTable from './CpTable.vue'
|
|
41
41
|
import CpTableColumnEditor from './CpTableColumnEditor.vue'
|
|
42
|
+
import CpTabs from './CpTabs.vue'
|
|
42
43
|
import CpTelInput from './CpTelInput.vue'
|
|
43
44
|
import CpTextarea from './CpTextarea.vue'
|
|
44
45
|
import CpToast from './CpToast.vue'
|
|
@@ -61,6 +62,7 @@ const Components = {
|
|
|
61
62
|
CpToaster,
|
|
62
63
|
CpToast,
|
|
63
64
|
CpBadge,
|
|
65
|
+
CpTabs,
|
|
64
66
|
CpHeading,
|
|
65
67
|
CpButton,
|
|
66
68
|
CpButtonGroup,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { action } from 'storybook/actions'
|
|
2
|
+
|
|
3
|
+
import type { Args, Meta, StoryObj } from '@storybook/vue3'
|
|
4
|
+
|
|
5
|
+
import CpTabs from '@/components/CpTabs.vue'
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'CpTabs',
|
|
9
|
+
component: CpTabs,
|
|
10
|
+
argTypes: {
|
|
11
|
+
defaultActiveIndex: {
|
|
12
|
+
control: 'number',
|
|
13
|
+
description: 'The index of the initially active tab',
|
|
14
|
+
},
|
|
15
|
+
isLoading: {
|
|
16
|
+
control: 'boolean',
|
|
17
|
+
description: 'Whether the tabs are in loading state',
|
|
18
|
+
},
|
|
19
|
+
tabs: {
|
|
20
|
+
control: 'object',
|
|
21
|
+
description: 'Array of tab objects with title, optional icon, and optional count',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof CpTabs>
|
|
25
|
+
|
|
26
|
+
export default meta
|
|
27
|
+
|
|
28
|
+
type Story = StoryObj<typeof meta>
|
|
29
|
+
|
|
30
|
+
const defaultTemplate = '<CpTabs v-bind="args" @on-tab-click="args.onOnTabClick" />'
|
|
31
|
+
const defaultRender = (args: Args) => ({
|
|
32
|
+
components: { CpTabs },
|
|
33
|
+
setup() {
|
|
34
|
+
return { args }
|
|
35
|
+
},
|
|
36
|
+
template: defaultTemplate,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const Default: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
defaultActiveIndex: 0,
|
|
42
|
+
isLoading: false,
|
|
43
|
+
tabs: [{ title: 'Tab 1' }, { title: 'Tab 2' }, { title: 'Tab 3' }],
|
|
44
|
+
onOnTabClick: action('tab-clicked'),
|
|
45
|
+
},
|
|
46
|
+
render: defaultRender,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const WithIcons: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
...Default.args,
|
|
52
|
+
tabs: [
|
|
53
|
+
{ title: 'Home', icon: 'home' },
|
|
54
|
+
{ title: 'Settings', icon: 'settings' },
|
|
55
|
+
{ title: 'User', icon: 'user' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
render: defaultRender,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const WithCounts: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
...Default.args,
|
|
64
|
+
tabs: [
|
|
65
|
+
{ title: 'Inbox', count: 5 },
|
|
66
|
+
{ title: 'Sent', count: 12 },
|
|
67
|
+
{ title: 'Drafts', count: 3 },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
render: defaultRender,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const WithIconsAndCounts: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
...Default.args,
|
|
76
|
+
tabs: [
|
|
77
|
+
{ title: 'Notifications', icon: 'bell', count: 8 },
|
|
78
|
+
{ title: 'Messages', icon: 'user', count: 3 },
|
|
79
|
+
{ title: 'Tasks', icon: 'check', count: 15 },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
render: defaultRender,
|
|
83
|
+
}
|