@citizenplane/pimp 18.9.36 → 18.10.1

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