Skip to content

Commit

Permalink
feat(NcCounterBubble): add count prop and humanize output
Browse files Browse the repository at this point in the history
Signed-off-by: Grigorii K. Shartsev <me@shgk.me>
  • Loading branch information
ShGKme committed Aug 20, 2024
1 parent 8ecdf87 commit a756430
Show file tree
Hide file tree
Showing 2 changed files with 306 additions and 19 deletions.
257 changes: 238 additions & 19 deletions src/components/NcCounterBubble/NcCounterBubble.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,186 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<docs>
<docs>

### Normal Counter
### Default usage

```
<NcCounterBubble>314+</NcCounterBubble>
```
NcCounterBubble displays a number from the `count` prop in a bubble.

### Outlined Counter (e.g team mentions)
By default, the number is **humanized** according to Nextcloud user's locale setting. Humanization can be disabled via `raw` prop.

```
<NcCounterBubble type="outlined">314+</NcCounterBubble>
```vue
<template>
<table>
<tr>
<th>count</th>
<th>default</th>
<th>raw</th>
</tr>
<tr v-for="num in numbers" :key="num">
<td>{{ num }}</td>
<td>
<NcCounterBubble :count="num" />
</td>
<td>
<NcCounterBubble :count="num" raw />
</td>
</tr>
</table>
</template>

<script>
export default {
setup() {
return {
numbers: [1, 9, 75, 450, 1042, 1750, 1999, 14567, 14567890, 2000000008],
}
},
}
</script>

<style scoped>
table {
border-collapse: collapse;
}

th,
td {
border: 1px solid var(--color-border);
padding: var(--default-grid-baseline) calc(var(--default-grid-baseline) * 2);
}

th {
color: var(--color-text-maxcontrast);
}
</style>
```

### Highlighted Counter (e.g direct mentions)
### Styles

Use different styles for different types of counters.

```
<NcCounterBubble type="highlighted">314+</NcCounterBubble>
<template>
<table>
<tr>
<th>type</th>
<th>counter</th>
<th>Usage example</th>
</tr>
<tr>
<td>'' (default)</td>
<td>
<NcCounterBubble :count="3" />
</td>
<td></td>
</tr>
<tr>
<td>outlined</td>
<td><NcCounterBubble :count="3" type="outlined" /></td>
<td>Team/group mentions</td>
</tr>
<tr>
<td>highlighted</td>
<td>
<NcCounterBubble :count="3" type="highlighted" />
</td>
<td>Direct mentions</td>
</tr>
<tr>
<td>outlined active</td>
<td class="active-like">
<NcCounterBubble :count="3" type="outlined" active />
</td>
<td>Same as "outlined", but in an "active" container</td>
</tr>
<tr>
<td>highlighted active</td>
<td class="active-like">
<NcCounterBubble :count="3" type="highlighted" active />
</td>
<td>Same as "highlighted", but in an "active" container</td>
</tr>
</table>
</template>

<style scoped>
table {
border-collapse: collapse;
}

th,
td {
border: 1px solid var(--color-border);
padding: var(--default-grid-baseline) calc(var(--default-grid-baseline) * 2);

&.active-like {
background-color: var(--color-primary-element);
}
}

th {
color: var(--color-text-maxcontrast);
}
</style>
```

</docs>
### Custom content (deprecated)

You can use the default slot to pass any custom content. If you pass a plain number to the default slot, without raw prop it will be humanized like via `count` prop.

**DEPRECATED:** passing count via slot content is **deprecated** and will be removed in the v9. Prefer using `count` prop for numbers or [NcChip](#/Components/NcChip) component for a custom content.

```vue
<template>
<div :class="counterClassObject"
class="counter-bubble__counter">
<slot />
</div>
<table>
<tr>
<th>content</th>
<th>default</th>
<th>raw</th>
</tr>
<tr v-for="num in numbers" :key="num">
<td>{{ num }}</td>
<td>
<NcCounterBubble>{{ num }}</NcCounterBubble>
</td>
<td>
<NcCounterBubble raw>{{ num }}</NcCounterBubble>
</td>
</tr>
</table>
</template>

<script>
export default {
setup() {
return {
numbers: ['314+', '16 rows', '24564'],
}
},
}
</script>

<style scoped>
table {
border-collapse: collapse;
}

th,
td {
border: 1px solid var(--color-border);
padding: var(--default-grid-baseline) calc(var(--default-grid-baseline) * 2);
}

th {
color: var(--color-text-maxcontrast);
}
</style>
```
</docs>

<script>
import { getCanonicalLocale } from '@nextcloud/l10n'

export default {
name: 'NcCounterBubble',
Expand All @@ -42,7 +192,7 @@ export default {
type: String,
default: '',
validator(value) {
return ['highlighted', 'outlined', ''].indexOf(value) !== -1
return ['highlighted', 'outlined', ''].includes(value)
},
},

Expand All @@ -55,6 +205,25 @@ export default {
type: Boolean,
default: false,
},

/**
* The count to display in the counter bubble.
* Alternatively, you can pass any value to the default slot.
*/
count: {
type: Number,
required: false,
default: undefined,
},

/**
* Disables humanization to display count or content as it is
*/
raw: {
type: Boolean,
required: false,
default: false,
},
},

computed: {
Expand All @@ -65,6 +234,57 @@ export default {
active: this.active,
}
},

humanizedCount() {
return this.humanizeCount(this.count)
},
},

methods: {
humanizeCount(count) {
if (this.raw) {
return count
}

const formatter = new Intl.NumberFormat(getCanonicalLocale(), {
notation: 'compact',
compactDisplay: 'short',
})

return formatter.format(count)
},

/**
* Get the humanized count from `count` prop
* @return {string | undefined}
*/
getHumanizedCount() {
// If we have count prop - just render from count
if (this.count !== undefined) {
return this.humanizedCount
}

// Raw value - render as it is
if (this.raw) {
return undefined
}

// If slot content is just a text with a number - process like count
if (this.$slots.default?.length === 1) {
const slotContent = this.$slots.default[0].text?.trim()
if (slotContent && /^\d+$/.test(slotContent)) {
const count = parseInt(slotContent, 10)
return this.humanizeCount(count)
}
}
},
},

render(h) {
return h('div', {
staticClass: 'counter-bubble__counter',
class: this.counterClassObject,
}, [this.getHumanizedCount() ?? this.$slots.default])
},
}

Expand All @@ -73,13 +293,11 @@ export default {
<style lang="scss" scoped>
.counter-bubble__counter {
--counter-bubble-line-height: 1em;
font-size: calc(var(--default-font-size) * .8);
font-size: var(--font-size-small, 13px);
overflow: hidden;
width: fit-content;
max-width: var(--default-clickable-area);
min-width: calc(var(--counter-bubble-line-height) + 2 * var(--default-grid-baseline)); // Make it not narrower than a circle
text-align: center;
text-overflow: ellipsis;
line-height: var(--counter-bubble-line-height);
padding: var(--default-grid-baseline);
border-radius: var(--border-radius-pill);
Expand Down Expand Up @@ -107,6 +325,7 @@ export default {
background: transparent;
box-shadow: inset 0 0 0 2px;
}

&--outlined.active {
color: var(--color-main-background);
box-shadow: inset 0 0 0 2px;
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/components/NcCounterBubble/NcCounterBubble.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { describe, it, expect } from '@jest/globals'
import { mount } from '@vue/test-utils'
import NcCounterBubble from '../../../../src/components/NcCounterBubble/NcCounterBubble.vue'

describe('NcCounterBubble', () => {
describe('displaying count', () => {
it('should render count from prop', () => {
const wrapper = mount(NcCounterBubble, { propsData: { count: 314 } })
expect(wrapper.text()).toBe('314')
})

it('should render non-number content as it is', () => {
const wrapper = mount(NcCounterBubble, { slots: { default: '14 rows' } })
expect(wrapper.text()).toBe('14 rows')
})
})

describe('with humanization', () => {
it('should render count 1020 with humanization as "1K"', () => {
const wrapper = mount(NcCounterBubble, { propsData: { count: 1042 } })
expect(wrapper.text()).toBe('1K')
})

it('should not humanize with raw', () => {
const wrapper = mount(NcCounterBubble, { propsData: { count: 1042, raw: true } })
expect(wrapper.text()).toBe('1042')
})

it('should render slot content 1020 with humanization as "1K"', () => {
const wrapper = mount(NcCounterBubble, { slots: { default: '1042' } })
expect(wrapper.text()).toBe('1K')
})

it('should render slot content 1020 as it is with raw prop', () => {
const wrapper = mount(NcCounterBubble, { propsData: { raw: true }, slots: { default: '1042' } })
expect(wrapper.text()).toBe('1042')
})
})

describe('with styling', () => {
it('should not have any additional classes', () => {
const wrapper = mount(NcCounterBubble)
expect(wrapper.classes('counter-bubble__counter--highlighted')).toBeFalsy()
expect(wrapper.classes('counter-bubble__counter--outlined')).toBeFalsy()
expect(wrapper.classes('active')).toBeFalsy()
})

it('should have class "counter-bubble__counter--highlighted" when type="highlighted"', () => {
const wrapper = mount(NcCounterBubble, { propsData: { type: 'highlighted' } })
expect(wrapper.classes('counter-bubble__counter--highlighted')).toBeTruthy()
})

it('should have class "counter-bubble__counter--outlined" when type="outlined"', () => {
const wrapper = mount(NcCounterBubble, { propsData: { type: 'outlined' } })
expect(wrapper.classes('counter-bubble__counter--outlined')).toBeTruthy()
})

it('should have class "active" when active', () => {
const wrapper = mount(NcCounterBubble, { propsData: { active: true } })
expect(wrapper.classes('active')).toBeTruthy()
})
})
})

0 comments on commit a756430

Please sign in to comment.