Skip to content

Commit 8181576

Browse files
northwordantfu
andauthored
feat(useTimeAgoIntl): add useTimaAgoIntl (#4821)
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
1 parent c527762 commit 8181576

File tree

6 files changed

+345
-0
lines changed

6 files changed

+345
-0
lines changed

packages/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export * from './useTextSelection'
123123
export * from './useTextareaAutosize'
124124
export * from './useThrottledRefHistory'
125125
export * from './useTimeAgo'
126+
export * from './useTimeAgoIntl'
126127
export * from './useTimeoutPoll'
127128
export * from './useTimestamp'
128129
export * from './useTitle'

packages/core/useTimeAgoIntl/demo.vue

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script setup lang="ts">
2+
import { timestamp, useTimeAgoIntl } from '@vueuse/core'
3+
import { computed, shallowRef } from 'vue'
4+
5+
const slider = shallowRef(0)
6+
const value = computed(() => timestamp() + slider.value ** 3)
7+
const timeAgoIntl = useTimeAgoIntl(value, { locale: 'en' })
8+
const timeAgoIntlZh = useTimeAgoIntl(value, { locale: 'zh' })
9+
</script>
10+
11+
<template>
12+
<div class="text-primary text-center">
13+
English: {{ timeAgoIntl }},
14+
Chinese: {{ timeAgoIntlZh }}
15+
</div>
16+
<input v-model="slider" class="slider" type="range" min="-3800" max="3800">
17+
<div class="text-center opacity-50">
18+
{{ slider ** 3 }}ms
19+
</div>
20+
</template>
21+
22+
<style>
23+
.slider {
24+
-webkit-appearance: none;
25+
width: 100%;
26+
background: rgba(125, 125, 125, 0.1);
27+
border-radius: 1rem;
28+
height: 1rem;
29+
opacity: 0.8;
30+
margin: 0.5rem 0;
31+
outline: none !important;
32+
transition: opacity 0.2s;
33+
}
34+
35+
.slider:hover {
36+
opacity: 1;
37+
}
38+
39+
.slider::-webkit-slider-thumb {
40+
-webkit-appearance: none;
41+
appearance: none;
42+
width: 1.3rem;
43+
height: 1.3rem;
44+
background: var(--vp-c-brand);
45+
cursor: pointer;
46+
border-radius: 50%;
47+
}
48+
</style>

packages/core/useTimeAgoIntl/index.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
category: Time
3+
---
4+
5+
# useTimeAgoIntl
6+
7+
Reactive time ago with i18n supported. Automatically update the time ago string when the time changes. Powered by `Intl.RelativeTimeFormat`.
8+
9+
## Usage
10+
11+
```js
12+
import { useTimeAgoIntl } from '@vueuse/core'
13+
14+
const timeAgoIntl = useTimeAgoIntl(new Date(2021, 0, 1))
15+
```
16+
17+
## Non-Reactivity Usage
18+
19+
In case you don't need the reactivity, you can use the `formatTimeAgo` function to get the formatted string instead of a Ref.
20+
21+
```js
22+
import { formatTimeAgoIntl } from '@vueuse/core'
23+
24+
const timeAgoIntl = formatTimeAgoIntl(new Date(2021, 0, 1)) // string
25+
```
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { shallowRef } from 'vue'
3+
import {
4+
formatTimeAgoIntl,
5+
formatTimeAgoIntlParts,
6+
useTimeAgoIntl,
7+
} from './index'
8+
9+
describe('formatTimeAgoIntlParts', () => {
10+
it('should format with spaces by default', () => {
11+
const parts1: Intl.RelativeTimeFormatPart[] = [
12+
{ type: 'integer', value: '5', unit: 'day' },
13+
{ type: 'literal', value: ' days' },
14+
]
15+
16+
expect(formatTimeAgoIntlParts(parts1)).toEqual('5 days')
17+
18+
const parts2: Intl.RelativeTimeFormatPart[] = [
19+
{ type: 'integer', value: '5', unit: 'day' },
20+
{ type: 'literal', value: '天后' },
21+
]
22+
expect(formatTimeAgoIntlParts(parts2)).toEqual('5 天后')
23+
})
24+
25+
it('should format with spaces if insertSpace is false', () => {
26+
const parts1: Intl.RelativeTimeFormatPart[] = [
27+
{ type: 'integer', value: '5', unit: 'day' },
28+
{ type: 'literal', value: ' days' },
29+
]
30+
31+
expect(formatTimeAgoIntlParts(parts1, { insertSpace: false })).toEqual('5 days')
32+
33+
const parts2: Intl.RelativeTimeFormatPart[] = [
34+
{ type: 'integer', value: '5', unit: 'day' },
35+
{ type: 'literal', value: '天后' },
36+
]
37+
38+
expect(formatTimeAgoIntlParts(parts2, { insertSpace: false })).toEqual('5天后')
39+
})
40+
41+
it('should use joinParts if provided', () => {
42+
const parts: Intl.RelativeTimeFormatPart[] = [
43+
{ type: 'integer', value: '5', unit: 'day' },
44+
{ type: 'literal', value: '天后' },
45+
]
46+
const result = formatTimeAgoIntlParts(parts, {
47+
joinParts: p => p.map(x => `[${x.value}]`).join('|'),
48+
})
49+
expect(result).toEqual('[5]|[天后]')
50+
})
51+
})
52+
53+
describe('formatTimeAgoIntl', () => {
54+
it('should format a past timestamp', () => {
55+
const now = Date.now()
56+
const past = new Date(now - 1000 * 60 * 5)
57+
58+
expect(formatTimeAgoIntl(past, {}, now)).toMatch('5')
59+
expect(formatTimeAgoIntl(past, { locale: 'en' }, now)).toEqual('5 minutes ago')
60+
expect(formatTimeAgoIntl(past, { locale: 'zh' }, now)).toEqual('5 分钟前')
61+
})
62+
63+
it('should format a future timestamp', () => {
64+
const now = Date.now()
65+
const future = new Date(now + 1000 * 60 * 5)
66+
67+
expect(formatTimeAgoIntl(future, { locale: 'en' }, now)).toEqual('in 5 minutes')
68+
})
69+
})
70+
71+
describe('useTimeAgoIntl', () => {
72+
it('should compute a reactive timeAgo string', () => {
73+
const now = Date.now()
74+
const past = shallowRef(now - 1000 * 60 * 5)
75+
76+
const timeAgo = useTimeAgoIntl(past)
77+
78+
expect(timeAgo.value).toMatch('5')
79+
})
80+
81+
it('should expose parts when controls is true', () => {
82+
const now = Date.now()
83+
const past = shallowRef(now - 1000 * 60 * 5)
84+
85+
const { timeAgoIntl, parts } = useTimeAgoIntl(past, { controls: true })
86+
87+
expect(timeAgoIntl.value).toMatch('5')
88+
expect(parts.value).toBeInstanceOf(Array)
89+
expect(parts.value[0]).toHaveProperty('type')
90+
expect(parts.value[0]).toHaveProperty('value')
91+
})
92+
})

packages/core/useTimeAgoIntl/index.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { Pausable } from '@vueuse/shared'
2+
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
3+
import { computed, toValue } from 'vue'
4+
import { useNow } from '../useNow'
5+
6+
type Locale = Intl.UnicodeBCP47LocaleIdentifier | Intl.Locale
7+
8+
export interface FormatTimeAgoIntlOptions {
9+
/**
10+
* The locale to format with
11+
*
12+
* @default undefined
13+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#locales
14+
*/
15+
locale?: Locale
16+
17+
/**
18+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#options
19+
*/
20+
relativeTimeFormatOptions?: Intl.RelativeTimeFormatOptions
21+
22+
/**
23+
* Whether to insert spaces between parts.
24+
*
25+
* Ignored if `joinParts` is provided.
26+
*
27+
* @default true
28+
*/
29+
insertSpace?: boolean
30+
31+
/**
32+
* Custom function to join the parts returned by `Intl.RelativeTimeFormat.formatToParts`.
33+
*
34+
* If provided, it will be used instead of the default join logic.
35+
*/
36+
joinParts?: (parts: Intl.RelativeTimeFormatPart[], locale?: Intl.UnicodeBCP47LocaleIdentifier | Intl.Locale) => string
37+
}
38+
39+
export interface UseTimeAgoIntlOptions<Controls extends boolean> extends FormatTimeAgoIntlOptions {
40+
/**
41+
* Expose more controls and the raw `parts` result.
42+
*
43+
* @default false
44+
*/
45+
controls?: Controls
46+
47+
/**
48+
* Update interval in milliseconds, set 0 to disable auto update
49+
*
50+
* @default 30_000
51+
*/
52+
updateInterval?: number
53+
}
54+
55+
type UseTimeAgoReturn<Controls extends boolean = false>
56+
= Controls extends true
57+
? { timeAgoIntl: ComputedRef<string>, parts: ComputedRef<Intl.RelativeTimeFormatPart[]> } & Pausable
58+
: ComputedRef<string>
59+
60+
export interface TimeAgoUnit {
61+
name: Intl.RelativeTimeFormatUnit
62+
ms: number
63+
}
64+
65+
const UNITS: TimeAgoUnit[] = [
66+
{ name: 'year', ms: 31_536_000_000 },
67+
{ name: 'month', ms: 2_592_000_000 },
68+
{ name: 'week', ms: 604_800_000 },
69+
{ name: 'day', ms: 86_400_000 },
70+
{ name: 'hour', ms: 3_600_000 },
71+
{ name: 'minute', ms: 60_000 },
72+
{ name: 'second', ms: 1_000 },
73+
]
74+
75+
/**
76+
* A reactive wrapper for `Intl.RelativeTimeFormat`.
77+
*/
78+
export function useTimeAgoIntl(time: MaybeRefOrGetter<Date | number | string>, options?: UseTimeAgoIntlOptions<false>): UseTimeAgoReturn<false>
79+
export function useTimeAgoIntl(time: MaybeRefOrGetter<Date | number | string>, options: UseTimeAgoIntlOptions<true>): UseTimeAgoReturn<true>
80+
export function useTimeAgoIntl(time: MaybeRefOrGetter<Date | number | string>, options: UseTimeAgoIntlOptions<boolean> = {}) {
81+
const {
82+
controls: exposeControls = false,
83+
updateInterval = 30_000,
84+
} = options
85+
86+
const { now, ...controls } = useNow({ interval: updateInterval, controls: true })
87+
88+
const result = computed(() =>
89+
getTimeAgoIntlResult(new Date(toValue(time)), options, toValue(now)),
90+
)
91+
92+
const parts = computed(() => result.value.parts)
93+
const timeAgoIntl = computed(() =>
94+
formatTimeAgoIntlParts(parts.value, {
95+
...options,
96+
locale: result.value.resolvedLocale,
97+
}),
98+
)
99+
100+
return exposeControls
101+
? { timeAgoIntl, parts, ...controls }
102+
: timeAgoIntl
103+
}
104+
105+
/**
106+
* Non-reactive version of useTimeAgoIntl
107+
*/
108+
export function formatTimeAgoIntl(
109+
from: Date,
110+
options: FormatTimeAgoIntlOptions = {},
111+
now: Date | number = Date.now(),
112+
): string {
113+
const { parts, resolvedLocale } = getTimeAgoIntlResult(from, options, now)
114+
return formatTimeAgoIntlParts(parts, {
115+
...options,
116+
locale: resolvedLocale,
117+
})
118+
}
119+
120+
/**
121+
* Get parts from `Intl.RelativeTimeFormat.formatToParts`.
122+
*/
123+
function getTimeAgoIntlResult(
124+
from: Date,
125+
options: FormatTimeAgoIntlOptions = {},
126+
now: Date | number = Date.now(),
127+
): { parts: Intl.RelativeTimeFormatPart[], resolvedLocale: Locale } {
128+
const {
129+
locale,
130+
relativeTimeFormatOptions = { numeric: 'auto' },
131+
} = options
132+
133+
const rtf = new Intl.RelativeTimeFormat(locale, relativeTimeFormatOptions)
134+
const { locale: resolvedLocale } = rtf.resolvedOptions()
135+
136+
const diff = +from - +now
137+
const absDiff = Math.abs(diff)
138+
139+
for (const { name, ms } of UNITS) {
140+
if (absDiff >= ms) {
141+
return {
142+
resolvedLocale,
143+
parts: rtf.formatToParts(Math.round(diff / ms), name),
144+
}
145+
}
146+
}
147+
148+
return {
149+
resolvedLocale,
150+
parts: rtf.formatToParts(0, 'second'),
151+
}
152+
}
153+
154+
/**
155+
* Format parts into a string
156+
*/
157+
export function formatTimeAgoIntlParts(
158+
parts: Intl.RelativeTimeFormatPart[],
159+
options: FormatTimeAgoIntlOptions = {},
160+
): string {
161+
const {
162+
insertSpace = true,
163+
joinParts,
164+
locale,
165+
} = options
166+
167+
if (typeof joinParts === 'function')
168+
return joinParts(parts, locale)
169+
170+
if (!insertSpace)
171+
return parts.map(part => part.value).join('')
172+
173+
return parts
174+
.map(part => part.value.trim())
175+
.join(' ')
176+
}

test/exports/core.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
extendRef: function
5151
formatDate: function
5252
formatTimeAgo: function
53+
formatTimeAgoIntl: function
54+
formatTimeAgoIntlParts: function
5355
get: function
5456
getLifeCycleTarget: function
5557
getSSRHandler: function
@@ -259,6 +261,7 @@
259261
useThrottledRefHistory: function
260262
useThrottleFn: function
261263
useTimeAgo: function
264+
useTimeAgoIntl: function
262265
useTimeout: function
263266
useTimeoutFn: function
264267
useTimeoutPoll: function

0 commit comments

Comments
 (0)