Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[next] feat(NcCounterBubble): add count prop for humanized count display #5990

Merged
merged 1 commit into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 241 additions & 19 deletions src/components/NcCounterBubble/NcCounterBubble.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,187 @@
- 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 { h } from 'vue'
import { getCanonicalLocale } from '@nextcloud/l10n'

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

Expand All @@ -55,6 +206,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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All props are optional by default, unless required: true is specified.

default: false,
},
},

computed: {
Expand All @@ -65,6 +235,59 @@ 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
const slotContent = this.$slots.default?.()?.[0]?.children
if (typeof slotContent === 'string') {
const textContent = slotContent.trim()
if (textContent && /^\d+$/.test(textContent)) {
const count = parseInt(textContent, 10)
return this.humanizeCount(count)
}
}
},
},

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

Expand All @@ -73,13 +296,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 +328,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 'vitest'
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, { props: { 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, { props: { count: 1042 } })
expect(wrapper.text()).toBe('1K')
})

it('should not humanize with raw', () => {
const wrapper = mount(NcCounterBubble, { props: { 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, { props: { 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, { props: { 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, { props: { type: 'outlined' } })
expect(wrapper.classes('counter-bubble__counter--outlined')).toBeTruthy()
})

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