@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citizenplane/pimp",
3
- "version": "18.9.36",
3
+ "version": "18.10.0",
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
@@ -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
- icon?: string
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>(), { defaultActiveIndex: 0 })
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 handleTabClick = (index: number) => {
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 [{ 'cpTabs__tab--isActive': activeTabIndex.value === tabIndex }, { 'cpTabs__tab--isLoading': props.isLoading }]
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 dynamicBadgeColor = (index: number) => (index === activeTabIndex.value ? 'accent' : 'neutral')
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
- underlineElement.style.width = `${activeTabElement.offsetWidth}px`
94
- underlineElement.style.transform = `translate3d(${activeTabElement.offsetLeft}px, 0, 0)`
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: 'smooth' })
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
- gap: var(--cp-spacing-md);
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
- bottom: 0;
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
- padding: var(--cp-spacing-md);
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-border-accent-solid);
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
- &__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);
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
- @include mx.media-query-min(769px) {
193
- .cpTabs {
194
- &__tab--isActive {
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
- @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;
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: '<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) => ({