@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citizenplane/pimp",
3
- "version": "9.15.4",
3
+ "version": "9.16.0",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8080",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -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 false
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>
@@ -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
+ }