diff --git a/package-lock.json b/package-lock.json index f381f1e89..657658a0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5167,6 +5167,10 @@ "resolved": "packages/uui-ref-node-user", "link": true }, + "node_modules/@umbraco-ui/uui-relative-time": { + "resolved": "packages/uui-relative-time", + "link": true + }, "node_modules/@umbraco-ui/uui-scroll-container": { "resolved": "packages/uui-scroll-container", "link": true @@ -24586,6 +24590,14 @@ "@umbraco-ui/uui-ref-node": "1.11.0" } }, + "packages/uui-relative-time": { + "name": "@umbraco-ui/uui-relative-time", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@umbraco-ui/uui-base": "1.9.0-rc.1" + } + }, "packages/uui-scroll-container": { "name": "@umbraco-ui/uui-scroll-container", "version": "1.11.0", diff --git a/packages/uui-base/lib/utils/Duration.ts b/packages/uui-base/lib/utils/Duration.ts new file mode 100644 index 000000000..fe9c80751 --- /dev/null +++ b/packages/uui-base/lib/utils/Duration.ts @@ -0,0 +1,406 @@ +const durationRe = + /^[-+]?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; +export const unitNames = [ + 'year', + 'month', + 'week', + 'day', + 'hour', + 'minute', + 'second', + 'millisecond', +] as const; +export type Unit = (typeof unitNames)[number]; + +// https://tc39.es/proposal-intl-duration-format/ +interface DurationFormatResolvedOptions { + locale: string; + style: 'long' | 'short' | 'narrow' | 'digital'; + years: 'long' | 'short' | 'narrow'; + yearsDisplay: 'always' | 'auto'; + months: 'long' | 'short' | 'narrow'; + monthsDisplay: 'always' | 'auto'; + weeks: 'long' | 'short' | 'narrow'; + weeksDisplay: 'always' | 'auto'; + days: 'long' | 'short' | 'narrow'; + daysDisplay: 'always' | 'auto'; + hours: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit'; + hoursDisplay: 'always' | 'auto'; + minutes: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit'; + minutesDisplay: 'always' | 'auto'; + seconds: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit'; + secondsDisplay: 'always' | 'auto'; + milliseconds: 'long' | 'short' | 'narrow' | 'numeric'; + millisecondsDisplay: 'always' | 'auto'; +} + +export type DurationFormatOptions = Partial< + Omit +>; + +const partsTable = [ + ['years', 'year'], + ['months', 'month'], + ['weeks', 'week'], + ['days', 'day'], + ['hours', 'hour'], + ['minutes', 'minute'], + ['seconds', 'second'], + ['milliseconds', 'millisecond'], +] as const; + +interface DurationPart { + type: 'integer' | 'literal' | 'element'; + value: string; +} + +const twoDigitFormatOptions = { minimumIntegerDigits: 2 }; + +export default class UUIDurationFormat { + #options: DurationFormatResolvedOptions; + + constructor(locale: string, options: DurationFormatOptions = {}) { + let style = String( + options.style ?? 'short', + ) as DurationFormatResolvedOptions['style']; + if ( + style !== 'long' && + style !== 'short' && + style !== 'narrow' && + style !== 'digital' + ) + style = 'short'; + let prevStyle: DurationFormatResolvedOptions['hours'] = + style === 'digital' ? 'numeric' : style; + const hours = options.hours ?? prevStyle; + prevStyle = hours === '2-digit' ? 'numeric' : hours; + const minutes = options.minutes ?? prevStyle; + prevStyle = minutes === '2-digit' ? 'numeric' : minutes; + const seconds = options.seconds ?? prevStyle; + prevStyle = seconds === '2-digit' ? 'numeric' : seconds; + const milliseconds = options.milliseconds ?? prevStyle; + this.#options = { + locale, + style, + years: options.years || style === 'digital' ? 'short' : style, + yearsDisplay: options.yearsDisplay === 'always' ? 'always' : 'auto', + months: options.months || style === 'digital' ? 'short' : style, + monthsDisplay: options.monthsDisplay === 'always' ? 'always' : 'auto', + weeks: options.weeks || style === 'digital' ? 'short' : style, + weeksDisplay: options.weeksDisplay === 'always' ? 'always' : 'auto', + days: options.days || style === 'digital' ? 'short' : style, + daysDisplay: options.daysDisplay === 'always' ? 'always' : 'auto', + hours, + hoursDisplay: + options.hoursDisplay === 'always' + ? 'always' + : style === 'digital' + ? 'always' + : 'auto', + minutes, + minutesDisplay: + options.minutesDisplay === 'always' + ? 'always' + : style === 'digital' + ? 'always' + : 'auto', + seconds, + secondsDisplay: + options.secondsDisplay === 'always' + ? 'always' + : style === 'digital' + ? 'always' + : 'auto', + milliseconds, + millisecondsDisplay: + options.millisecondsDisplay === 'always' ? 'always' : 'auto', + }; + } + + resolvedOptions() { + return this.#options; + } + + formatToParts(duration: Duration): DurationPart[] { + const list: string[] = []; + const options = this.#options; + const style = options.style; + const locale = options.locale; + for (const [unit, nfUnit] of partsTable) { + const value = duration[unit]; + if (options[`${unit}Display`] === 'auto' && !value) continue; + const unitStyle = options[unit]; + const nfOpts = + unitStyle === '2-digit' + ? twoDigitFormatOptions + : unitStyle === 'numeric' + ? {} + : { style: 'unit', unit: nfUnit, unitDisplay: unitStyle }; + list.push(new Intl.NumberFormat(locale, nfOpts).format(value)); + } + + return new (Intl as any).ListFormat(locale, { + type: 'unit', + style: style === 'digital' ? 'short' : style, + }).formatToParts(list); + } + + format(duration: Duration) { + return this.formatToParts(duration) + .map(p => p.value) + .join(''); + } +} + +export const isDuration = (str: string) => durationRe.test(str); +type Sign = -1 | 0 | 1; + +// https://tc39.es/proposal-temporal/docs/duration.html +export class Duration { + readonly sign: Sign; + readonly blank: boolean; + + constructor( + public readonly years = 0, + public readonly months = 0, + public readonly weeks = 0, + public readonly days = 0, + public readonly hours = 0, + public readonly minutes = 0, + public readonly seconds = 0, + public readonly milliseconds = 0, + ) { + // Account for -0 + this.years ||= 0; + this.sign ||= Math.sign(this.years) as Sign; + this.months ||= 0; + this.sign ||= Math.sign(this.months) as Sign; + this.weeks ||= 0; + this.sign ||= Math.sign(this.weeks) as Sign; + this.days ||= 0; + this.sign ||= Math.sign(this.days) as Sign; + this.hours ||= 0; + this.sign ||= Math.sign(this.hours) as Sign; + this.minutes ||= 0; + this.sign ||= Math.sign(this.minutes) as Sign; + this.seconds ||= 0; + this.sign ||= Math.sign(this.seconds) as Sign; + this.milliseconds ||= 0; + this.sign ||= Math.sign(this.milliseconds) as Sign; + this.blank = this.sign === 0; + } + + abs() { + return new Duration( + Math.abs(this.years), + Math.abs(this.months), + Math.abs(this.weeks), + Math.abs(this.days), + Math.abs(this.hours), + Math.abs(this.minutes), + Math.abs(this.seconds), + Math.abs(this.milliseconds), + ); + } + + static from(durationLike: unknown): Duration { + if (typeof durationLike === 'string') { + const str = String(durationLike).trim(); + const factor = str.startsWith('-') ? -1 : 1; + const parsed = str + .match(durationRe) + ?.slice(1) + .map(x => (Number(x) || 0) * factor); + if (!parsed) return new Duration(); + return new Duration(...parsed); + } else if (typeof durationLike === 'object') { + const { + years, + months, + weeks, + days, + hours, + minutes, + seconds, + milliseconds, + } = durationLike as Record; + return new Duration( + years, + months, + weeks, + days, + hours, + minutes, + seconds, + milliseconds, + ); + } + throw new RangeError('invalid duration'); + } + + static compare(one: unknown, two: unknown): -1 | 0 | 1 { + const now = Date.now(); + const oneApplied = Math.abs( + applyDuration(now, Duration.from(one)).getTime() - now, + ); + const twoApplied = Math.abs( + applyDuration(now, Duration.from(two)).getTime() - now, + ); + return oneApplied > twoApplied ? -1 : oneApplied < twoApplied ? 1 : 0; + } + + toLocaleString(locale: string, opts: DurationFormatOptions) { + return new UUIDurationFormat(locale, opts).format(this); + } +} + +export function applyDuration(date: Date | number, duration: Duration): Date { + const r = new Date(date); + r.setFullYear(r.getFullYear() + duration.years); + r.setMonth(r.getMonth() + duration.months); + r.setDate(r.getDate() + duration.weeks * 7 + duration.days); + r.setHours(r.getHours() + duration.hours); + r.setMinutes(r.getMinutes() + duration.minutes); + r.setSeconds(r.getSeconds() + duration.seconds); + return r; +} + +export function elapsedTime( + date: Date, + precision: Unit = 'second', + now = Date.now(), +): Duration { + const delta = date.getTime() - now; + if (delta === 0) return new Duration(); + const sign = Math.sign(delta); + const ms = Math.abs(delta); + const sec = Math.floor(ms / 1000); + const min = Math.floor(sec / 60); + const hr = Math.floor(min / 60); + const day = Math.floor(hr / 24); + const month = Math.floor(day / 30); + const year = Math.floor(month / 12); + const i = unitNames.indexOf(precision) || unitNames.length; + return new Duration( + i >= 0 ? year * sign : 0, + i >= 1 ? (month - year * 12) * sign : 0, + 0, + i >= 3 ? (day - month * 30) * sign : 0, + i >= 4 ? (hr - day * 24) * sign : 0, + i >= 5 ? (min - hr * 60) * sign : 0, + i >= 6 ? (sec - min * 60) * sign : 0, + i >= 7 ? (ms - sec * 1000) * sign : 0, + ); +} + +interface RoundingOpts { + relativeTo: Date | number; +} + +export function roundToSingleUnit( + duration: Duration, + { relativeTo = Date.now() }: Partial = {}, +): Duration { + relativeTo = new Date(relativeTo); + if (duration.blank) return duration; + const sign = duration.sign; + let years = Math.abs(duration.years); + let months = Math.abs(duration.months); + let weeks = Math.abs(duration.weeks); + let days = Math.abs(duration.days); + let hours = Math.abs(duration.hours); + let minutes = Math.abs(duration.minutes); + let seconds = Math.abs(duration.seconds); + let milliseconds = Math.abs(duration.milliseconds); + + if (milliseconds >= 900) seconds += Math.round(milliseconds / 1000); + if (seconds || minutes || hours || days || weeks || months || years) { + milliseconds = 0; + } + + if (seconds >= 55) minutes += Math.round(seconds / 60); + if (minutes || hours || days || weeks || months || years) seconds = 0; + + if (minutes >= 55) hours += Math.round(minutes / 60); + if (hours || days || weeks || months || years) minutes = 0; + + if (days && hours >= 12) days += Math.round(hours / 24); + if (!days && hours >= 21) days += Math.round(hours / 24); + if (days || weeks || months || years) hours = 0; + + // Resolve calendar dates + const currentYear = relativeTo.getFullYear(); + const currentMonth = relativeTo.getMonth(); + const currentDate = relativeTo.getDate(); + if (days >= 27 || years + months + days) { + const newMonthDate = new Date(relativeTo); + newMonthDate.setDate(1); + newMonthDate.setMonth(currentMonth + months * sign + 1); + newMonthDate.setDate(0); + const monthDateCorrection = Math.max( + 0, + currentDate - newMonthDate.getDate(), + ); + + const newDate = new Date(relativeTo); + newDate.setFullYear(currentYear + years * sign); + newDate.setDate(currentDate - monthDateCorrection); + newDate.setMonth(currentMonth + months * sign); + newDate.setDate(currentDate - monthDateCorrection + days * sign); + const yearDiff = newDate.getFullYear() - relativeTo.getFullYear(); + const monthDiff = newDate.getMonth() - relativeTo.getMonth(); + const daysDiff = + Math.abs(Math.round((Number(newDate) - Number(relativeTo)) / 86400000)) + + monthDateCorrection; + const monthsDiff = Math.abs(yearDiff * 12 + monthDiff); + if (daysDiff < 27) { + if (days >= 6) { + weeks += Math.round(days / 7); + days = 0; + } else { + days = daysDiff; + } + months = years = 0; + } else if (monthsDiff < 11) { + months = monthsDiff; + years = 0; + } else { + months = 0; + years = yearDiff * sign; + } + if (months || years) days = 0; + } + if (years) months = 0; + + if (weeks >= 4) months += Math.round(weeks / 4); + if (months || years) weeks = 0; + if (days && weeks && !months && !years) { + weeks += Math.round(days / 7); + days = 0; + } + + return new Duration( + years * sign, + months * sign, + weeks * sign, + days * sign, + hours * sign, + minutes * sign, + seconds * sign, + milliseconds * sign, + ); +} + +export function getRelativeTimeUnit( + duration: Duration, + opts?: Partial, +): [number, Intl.RelativeTimeFormatUnit] { + const rounded = roundToSingleUnit(duration, opts); + if (rounded.blank) return [0, 'second']; + for (const unit of unitNames) { + if (unit === 'millisecond') continue; + const val = rounded[`${unit}s` as keyof Duration] as number; + if (val) return [val, unit]; + } + return [0, 'second']; +} diff --git a/packages/uui-base/lib/utils/index.ts b/packages/uui-base/lib/utils/index.ts index 495946cd4..9148c92d6 100644 --- a/packages/uui-base/lib/utils/index.ts +++ b/packages/uui-base/lib/utils/index.ts @@ -1,3 +1,4 @@ +export * from './Duration'; export * from './Timer'; export * from './demandCustomElement'; export * from './drag'; diff --git a/packages/uui-relative-time/README.md b/packages/uui-relative-time/README.md new file mode 100644 index 000000000..f5e987be7 --- /dev/null +++ b/packages/uui-relative-time/README.md @@ -0,0 +1,31 @@ +# uui-relative-time + +![npm](https://img.shields.io/npm/v/@umbraco-ui/uui-relative-time?logoColor=%231B264F) + +Umbraco style relative-time component. + +## Installation + +### ES imports + +```zsh +npm i @umbraco-ui/uui-relative-time +``` + +Import the registration of `` via: + +```javascript +import '@umbraco-ui/uui-relative-time'; +``` + +When looking to leverage the `UUIRelativeTimeElement` base class as a type and/or for extension purposes, do so via: + +```javascript +import { UUIRelativeTimeElement } from '@umbraco-ui/uui-relative-time'; +``` + +## Usage + +```html + +``` diff --git a/packages/uui-relative-time/lib/index.ts b/packages/uui-relative-time/lib/index.ts new file mode 100644 index 000000000..6bd88237b --- /dev/null +++ b/packages/uui-relative-time/lib/index.ts @@ -0,0 +1 @@ +export * from './uui-relative-time.element'; diff --git a/packages/uui-relative-time/lib/uui-relative-time.element.ts b/packages/uui-relative-time/lib/uui-relative-time.element.ts new file mode 100644 index 000000000..ec0949dcc --- /dev/null +++ b/packages/uui-relative-time/lib/uui-relative-time.element.ts @@ -0,0 +1,505 @@ +import { defineElement } from '@umbraco-ui/uui-base/lib/registration'; +import { + Duration, + elapsedTime, + getRelativeTimeUnit, + isDuration, + Unit, + unitNames, +} from '@umbraco-ui/uui-base/lib/utils'; + +export type Format = 'duration' | 'relative' | 'datetime'; +export type FormatStyle = 'long' | 'short' | 'narrow'; +export type Tense = 'auto' | 'past' | 'future'; + +const emptyDuration = new Duration(); + +export class UUIRelativeTimeUpdatedEvent extends Event { + constructor( + public oldText: string, + public newText: string, + public oldTitle: string, + public newTitle: string, + ) { + super('relative-time-updated', { bubbles: true, composed: true }); + } +} + +function getUnitFactor(el: UUIRelativeTimeElement): number { + if (!el.date) return Infinity; + if (el.format === 'duration') { + const precision = el.precision; + if (precision === 'second') { + return 1000; + } else if (precision === 'minute') { + return 60 * 1000; + } + } + const ms = Math.abs(Date.now() - el.date.getTime()); + if (ms < 60 * 1000) return 1000; + if (ms < 60 * 60 * 1000) return 60 * 1000; + return 60 * 60 * 1000; +} + +const dateObserver = new (class { + elements: Set = new Set(); + time = Infinity; + + observe(element: UUIRelativeTimeElement) { + if (this.elements.has(element)) return; + this.elements.add(element); + const date = element.date; + if (date?.getTime()) { + const ms = getUnitFactor(element); + const time = Date.now() + ms; + if (time < this.time) { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.update(), ms); + this.time = time; + } + } + } + + unobserve(element: UUIRelativeTimeElement) { + if (!this.elements.has(element)) return; + this.elements.delete(element); + } + + timer: ReturnType = -1 as unknown as ReturnType< + typeof setTimeout + >; + update() { + clearTimeout(this.timer); + if (!this.elements.size) return; + + let nearestDistance = Infinity; + for (const timeEl of this.elements) { + nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)); + timeEl.update(); + } + this.time = Math.min(60 * 60 * 1000, nearestDistance); + this.timer = setTimeout(() => this.update(), this.time); + this.time += Date.now(); + } +})(); + +/** + * @element uui-relative-time + */ +@defineElement('uui-relative-time') +export class UUIRelativeTimeElement + extends HTMLElement + implements Intl.DateTimeFormatOptions +{ + #customTitle = false; + #updating: false | Promise = false; + + get #lang() { + return ( + this.closest('[lang]')?.getAttribute('lang') ?? + this.ownerDocument.documentElement.getAttribute('lang') ?? + 'default' + ); + } + + #renderRoot: Node = this.shadowRoot + ? this.shadowRoot + : this.attachShadow + ? this.attachShadow({ mode: 'open' }) + : this; + + static get observedAttributes() { + return [ + 'second', + 'minute', + 'hour', + 'weekday', + 'day', + 'month', + 'year', + 'time-zone-name', + 'threshold', + 'tense', + 'precision', + 'format', + 'format-style', + 'no-title', + 'datetime', + 'lang', + 'title', + ]; + } + + // Internal: Format the ISO 8601 timestamp according to the user agent's + // locale-aware formatting rules. The element's existing `title` attribute + // value takes precedence over this custom format. + // + // Returns a formatted time String. + #getFormattedTitle(date: Date): string | undefined { + return new Intl.DateTimeFormat(this.#lang, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }).format(date); + } + + #resolveFormat(duration: Duration): Format { + const format: string = this.format; + if (format === 'datetime') return 'datetime'; + if (format === 'duration') return 'duration'; + + if ( + format === 'relative' && + typeof Intl !== 'undefined' && + Intl.RelativeTimeFormat + ) { + const tense = this.tense; + if (tense === 'past' || tense === 'future') return 'relative'; + if (Duration.compare(duration, this.threshold) === 1) return 'relative'; + } + return 'datetime'; + } + + #getDurationFormat(duration: Duration): string { + const locale = this.#lang; + const style = this.formatStyle; + const tense = this.tense; + if ( + (tense === 'past' && duration.sign !== -1) || + (tense === 'future' && duration.sign !== 1) + ) { + duration = emptyDuration; + } + const display = `${this.precision}sDisplay`; + if (duration.blank) { + return emptyDuration.toLocaleString(locale, { + style, + [display]: 'always', + }); + } + return duration.abs().toLocaleString(locale, { style }); + } + + #getRelativeFormat(duration: Duration): string { + const relativeFormat = new Intl.RelativeTimeFormat(this.#lang, { + numeric: 'auto', + style: this.formatStyle, + }); + const tense = this.tense; + if (tense === 'future' && duration.sign !== 1) duration = emptyDuration; + if (tense === 'past' && duration.sign !== -1) duration = emptyDuration; + const [int, unit] = getRelativeTimeUnit(duration); + if (unit === 'second' && int < 10) { + return relativeFormat.format( + 0, + this.precision === 'millisecond' ? 'second' : this.precision, + ); + } + return relativeFormat.format(int, unit); + } + + #getDateTimeFormat(date: Date): string { + const formatter = new Intl.DateTimeFormat(this.#lang, { + second: this.second, + minute: this.minute, + hour: this.hour, + weekday: this.weekday, + day: this.day, + month: this.month, + year: this.year, + timeZoneName: this.timeZoneName, + }); + return `${formatter.format(date)}`.trim(); + } + + get second() { + const second = this.getAttribute('second'); + if (second === 'numeric' || second === '2-digit') return second; + return undefined; + } + + set second(value: 'numeric' | '2-digit' | undefined) { + this.setAttribute('second', value ?? ''); + } + + get minute() { + const minute = this.getAttribute('minute'); + if (minute === 'numeric' || minute === '2-digit') return minute; + return undefined; + } + + set minute(value: 'numeric' | '2-digit' | undefined) { + this.setAttribute('minute', value ?? ''); + } + + get hour() { + const hour = this.getAttribute('hour'); + if (hour === 'numeric' || hour === '2-digit') return hour; + return undefined; + } + + set hour(value: 'numeric' | '2-digit' | undefined) { + this.setAttribute('hour', value ?? ''); + } + + get weekday() { + const weekday = this.getAttribute('weekday'); + if (weekday === 'long' || weekday === 'short' || weekday === 'narrow') { + return weekday; + } + if (this.format === 'datetime' && weekday !== '') return this.formatStyle; + + return undefined; + } + + set weekday(value: 'short' | 'long' | 'narrow' | undefined) { + this.setAttribute('weekday', value ?? ''); + } + + get day() { + const day = this.getAttribute('day') ?? 'numeric'; + if (day === 'numeric' || day === '2-digit') return day; + + return undefined; + } + + set day(value: 'numeric' | '2-digit' | undefined) { + this.setAttribute('day', value ?? ''); + } + + get month() { + const format = this.format; + let month = this.getAttribute('month'); + if (month === '') return undefined; + month ??= format === 'datetime' ? this.formatStyle : 'short'; + if ( + month === 'numeric' || + month === '2-digit' || + month === 'short' || + month === 'long' || + month === 'narrow' + ) { + return month; + } + + return undefined; + } + + set month( + value: 'numeric' | '2-digit' | 'short' | 'long' | 'narrow' | undefined, + ) { + this.setAttribute('month', value ?? ''); + } + + get year() { + const year = this.getAttribute('year'); + if (year === 'numeric' || year === '2-digit') return year; + + if ( + !this.hasAttribute('year') && + new Date().getUTCFullYear() !== this.date?.getUTCFullYear() + ) { + return 'numeric'; + } + + return undefined; + } + + set year(value: 'numeric' | '2-digit' | undefined) { + this.setAttribute('year', value ?? ''); + } + + get timeZoneName() { + const name = this.getAttribute('time-zone-name'); + if ( + name === 'long' || + name === 'short' || + name === 'shortOffset' || + name === 'longOffset' || + name === 'shortGeneric' || + name === 'longGeneric' + ) { + return name; + } + return undefined; + } + + set timeZoneName( + value: + | 'long' + | 'short' + | 'shortOffset' + | 'longOffset' + | 'shortGeneric' + | 'longGeneric' + | undefined, + ) { + this.setAttribute('time-zone-name', value ?? ''); + } + + get threshold(): string { + const threshold = this.getAttribute('threshold'); + return threshold && isDuration(threshold) ? threshold : 'P30D'; + } + + set threshold(value: string) { + this.setAttribute('threshold', value); + } + + get tense(): Tense { + const tense = this.getAttribute('tense'); + if (tense === 'past') return 'past'; + if (tense === 'future') return 'future'; + return 'auto'; + } + + set tense(value: Tense) { + this.setAttribute('tense', value); + } + + get precision(): Unit { + const precision = this.getAttribute('precision') as unknown as Unit; + if (unitNames.includes(precision)) return precision; + return 'second'; + } + + set precision(value: Unit) { + this.setAttribute('precision', value); + } + + get format(): Format { + const format = this.getAttribute('format'); + if (format === 'datetime') return 'datetime'; + if (format === 'relative') return 'relative'; + if (format === 'duration') return 'duration'; + return 'relative'; + } + + set format(value: Format) { + this.setAttribute('format', value); + } + + get formatStyle(): FormatStyle { + const formatStyle = this.getAttribute('format-style'); + if (formatStyle === 'long') return 'long'; + if (formatStyle === 'short') return 'short'; + if (formatStyle === 'narrow') return 'narrow'; + const format = this.format; + if (format === 'datetime') return 'short'; + return 'long'; + } + + set formatStyle(value: FormatStyle) { + this.setAttribute('format-style', value); + } + + get noTitle(): boolean { + return this.hasAttribute('no-title'); + } + + set noTitle(value: boolean | undefined) { + this.toggleAttribute('no-title', value); + } + + get datetime() { + return this.getAttribute('datetime') || ''; + } + + set datetime(value: string) { + this.setAttribute('datetime', value); + } + + get date() { + const parsed = Date.parse(this.datetime); + return Number.isNaN(parsed) ? null : new Date(parsed); + } + + set date(value: Date | null) { + this.datetime = value?.toISOString() || ''; + } + + connectedCallback(): void { + this.update(); + } + + disconnectedCallback(): void { + dateObserver.unobserve(this); + } + + // Internal: Refresh the time element's formatted date when an attribute changes. + attributeChangedCallback( + attrName: string, + oldValue: unknown, + newValue: unknown, + ): void { + if (oldValue === newValue) return; + if (attrName === 'title') { + this.#customTitle = + newValue !== null && + (this.date && this.#getFormattedTitle(this.date)) !== newValue; + } + if (!this.#updating && !(attrName === 'title' && this.#customTitle)) { + this.#updating = (async () => { + await Promise.resolve(); + this.update(); + })(); + } + } + + update() { + const oldText: string = + this.#renderRoot.textContent ?? this.textContent ?? ''; + const oldTitle: string = this.getAttribute('title') ?? ''; + let newTitle: string = oldTitle; + const date = this.date; + if (typeof Intl === 'undefined' || !Intl.DateTimeFormat || !date) { + this.#renderRoot.textContent = oldText; + return; + } + const now = Date.now(); + if (!this.#customTitle) { + newTitle = this.#getFormattedTitle(date) ?? ''; + if (newTitle && !this.noTitle) this.setAttribute('title', newTitle); + } + + const duration = elapsedTime(date, this.precision, now); + const format = this.#resolveFormat(duration); + let newText = oldText; + if (format === 'duration') { + newText = this.#getDurationFormat(duration); + } else if (format === 'relative') { + newText = this.#getRelativeFormat(duration); + } else { + newText = this.#getDateTimeFormat(date); + } + + if (newText) { + this.#renderRoot.textContent = newText; + } else if (this.shadowRoot === this.#renderRoot && this.textContent) { + // Ensure invalid dates fall back to lightDOM text content + this.#renderRoot.textContent = this.textContent; + } + + if (newText !== oldText || newTitle !== oldTitle) { + this.dispatchEvent( + new UUIRelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle), + ); + } + + if (format === 'relative' || format === 'duration') { + dateObserver.observe(this); + } else { + dateObserver.unobserve(this); + } + this.#updating = false; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'uui-relative-time': UUIRelativeTimeElement; + } +} diff --git a/packages/uui-relative-time/lib/uui-relative-time.story.ts b/packages/uui-relative-time/lib/uui-relative-time.story.ts new file mode 100644 index 000000000..149e7babc --- /dev/null +++ b/packages/uui-relative-time/lib/uui-relative-time.story.ts @@ -0,0 +1,126 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +import './uui-relative-time.element'; +import type { UUIRelativeTimeElement } from './uui-relative-time.element'; +import readme from '../README.md?raw'; + +const meta: Meta = { + id: 'uui-relative-time', + title: 'Displays/Relative Time', + component: 'uui-relative-time', + parameters: { + readme: { markdown: readme }, + docs: { + source: { + code: ``, + }, + }, + }, +}; + +const now = new Date(); + +const yesterday = new Date(now); +yesterday.setDate(now.getDate() - 1); + +const tomorrow = new Date(now); +tomorrow.setDate(now.getDate() + 1); + +const daysAgo = new Date(now); +daysAgo.setDate(now.getDate() - 15); + +const monthsAgo = new Date(now); +monthsAgo.setMonth(now.getMonth() - 3); + +const format = ['duration', 'relative', 'datetime']; +const tense = ['auto', 'past', 'future']; +const precision = [ + 'year', + 'month', + 'week', + 'day', + 'hour', + 'minute', + 'second', + 'millisecond', +]; + +export default meta; +type Story = StoryObj; + +const Template: Story = { + render: (args: any) => html` + February 16th, 2005 + `, + args: { + datetime: '2005-02-16T16:30:00-08:00', + format: 'relative', + tense: 'auto', + precision: 'second', + }, + argTypes: { + datetime: { control: 'date' }, + tense: { + options: tense, + control: { type: 'select' }, + }, + format: { + options: format, + control: { type: 'select' }, + }, + precision: { + options: precision, + control: { type: 'select' }, + }, + }, +}; + +export const Overview: Story = { + ...Template, + args: { datetime: '2005-02-16T16:30:00-08:00' }, +}; + +export const Yesterday: Story = { + ...Template, + args: { + datetime: yesterday.toISOString(), + format: 'relative', + precision: 'day', + tense: 'past', + }, +}; + +export const Tomorrow: Story = { + ...Template, + args: { + datetime: tomorrow.toISOString(), + format: 'relative', + tense: 'auto', + }, +}; + +export const DaysAgo: Story = { + ...Template, + args: { + datetime: daysAgo.toISOString(), + format: 'relative', + precision: 'day', + tense: 'past', + }, +}; + +export const MonthsAgo: Story = { + ...Template, + args: { + datetime: monthsAgo.toISOString(), + format: 'relative', + tense: 'past', + }, +}; diff --git a/packages/uui-relative-time/lib/uui-relative-time.test.ts b/packages/uui-relative-time/lib/uui-relative-time.test.ts new file mode 100644 index 000000000..23c02bb1d --- /dev/null +++ b/packages/uui-relative-time/lib/uui-relative-time.test.ts @@ -0,0 +1,18 @@ +import { html, fixture, expect } from '@open-wc/testing'; +import { UUIRelativeTimeElement } from './uui-relative-time.element'; + +describe('UUIRelativeTimeElement', () => { + let element: UUIRelativeTimeElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UUIRelativeTimeElement); + }); + + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(); + }); +}); diff --git a/packages/uui-relative-time/package.json b/packages/uui-relative-time/package.json new file mode 100644 index 000000000..a1694e0af --- /dev/null +++ b/packages/uui-relative-time/package.json @@ -0,0 +1,44 @@ +{ + "name": "@umbraco-ui/uui-relative-time", + "version": "0.0.0", + "license": "MIT", + "keywords": [ + "Umbraco", + "Custom elements", + "Web components", + "UI", + "Lit", + "Relative Time" + ], + "description": "Umbraco UI relative-time component", + "repository": { + "type": "git", + "url": "https://github.com/umbraco/Umbraco.UI.git", + "directory": "packages/uui-relative-time" + }, + "bugs": { + "url": "https://github.com/umbraco/Umbraco.UI/issues" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "types": "./lib/index.d.ts", + "type": "module", + "customElements": "custom-elements.json", + "files": [ + "lib/**/*.d.ts", + "lib/**/*.js", + "custom-elements.json" + ], + "dependencies": { + "@umbraco-ui/uui-base": "1.9.0-rc.1" + }, + "scripts": { + "build": "npm run analyze && tsc --build --force && rollup -c rollup.config.js", + "clean": "tsc --build --clean && rimraf -g dist lib/*.js lib/**/*.js *.tgz lib/**/*.d.ts custom-elements.json", + "analyze": "web-component-analyzer **/*.element.ts --outFile custom-elements.json" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://uui.umbraco.com/?path=/story/uui-relative-time" +} diff --git a/packages/uui-relative-time/rollup.config.js b/packages/uui-relative-time/rollup.config.js new file mode 100644 index 000000000..34524a90d --- /dev/null +++ b/packages/uui-relative-time/rollup.config.js @@ -0,0 +1,5 @@ +import { UUIProdConfig } from '../rollup-package.config.mjs'; + +export default UUIProdConfig({ + entryPoints: ['index'], +}); diff --git a/packages/uui-relative-time/tsconfig.json b/packages/uui-relative-time/tsconfig.json new file mode 100644 index 000000000..40d176776 --- /dev/null +++ b/packages/uui-relative-time/tsconfig.json @@ -0,0 +1,17 @@ +// Don't edit this file directly. It is generated by /scripts/generate-ts-config.js + +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./lib", + "composite": true + }, + "include": ["./**/*.ts"], + "exclude": ["./**/*.test.ts", "./**/*.story.ts"], + "references": [ + { + "path": "../uui-base" + } + ] +} diff --git a/packages/uui/lib/index.ts b/packages/uui/lib/index.ts index 7a6290236..ec7980efa 100644 --- a/packages/uui/lib/index.ts +++ b/packages/uui/lib/index.ts @@ -80,3 +80,5 @@ export * from '@umbraco-ui/uui-toast-notification-layout/lib'; export * from '@umbraco-ui/uui-toast-notification/lib'; export * from '@umbraco-ui/uui-toggle/lib'; export * from '@umbraco-ui/uui-visually-hidden/lib'; + +export * from '@umbraco-ui/uui-relative-time/lib/index.js';