diff --git a/.changeset/react-first-relative-time.md b/.changeset/react-first-relative-time.md new file mode 100644 index 00000000000..0032a496745 --- /dev/null +++ b/.changeset/react-first-relative-time.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add an experimental React-only RelativeTime implementation, available through `@primer/react/experimental`. The established RelativeTime component remains backed by the custom element by default; the new implementation can be enabled internally with `primer_react_relative_time`. diff --git a/.github/skills/primer-npm-packages-integration/SKILL.md b/.github/skills/primer-npm-packages-integration/SKILL.md new file mode 100644 index 00000000000..90ef1e0be45 --- /dev/null +++ b/.github/skills/primer-npm-packages-integration/SKILL.md @@ -0,0 +1,60 @@ +--- +name: primer-npm-packages-integration +description: 'Use when monitoring a Primer React pull request labeled `integration-tests: recommended` and a fresh github-ui npm-packages integration run is needed after opening or updating the PR. Covers label verification, workflow dispatch, and run tracking with the GitHub CLI.' +--- + +# Primer npm-packages integration + +The github-ui integration workflow builds npm artifacts from a Primer React pull +request and installs them into a generated github-ui pull request. + +## When to use + +Use this skill only when: + +- The Primer React pull request has the `integration-tests: recommended` label. +- A new integration run is needed, such as after opening the pull request or + pushing a change that needs downstream validation. + +Do not dispatch the workflow when the label is absent. + +## Dispatch the workflow + +Use the pull request URL as the workflow input: + +```bash +PR_URL="$(gh pr view --repo primer/react --json url --jq .url)" + +gh pr view "$PR_URL" --repo primer/react --json labels --jq '.labels[].name' | + rg -qx 'integration-tests: recommended' + +gh workflow run primer-npm-packages-integration.yml \ + --repo github/github-ui \ + --ref main \ + -f "prc-pr=$PR_URL" +``` + +The label check exits nonzero when the pull request is not recommended for +integration testing. Do not bypass it. + +## Track the run + +Find the dispatched workflow, then inspect the generated github-ui pull request +and its checks: + +```bash +gh run list \ + --repo github/github-ui \ + --workflow primer-npm-packages-integration.yml \ + --limit 5 + +gh pr list \ + --repo github/github-ui \ + --head "prc-npm-packages-integration-for-" \ + --state open + +gh pr checks --repo github/github-ui +``` + +The generated integration pull request is automated and must remain a draft. +Never modify or merge it manually. diff --git a/e2e/components/DataTable.test.ts b/e2e/components/DataTable.test.ts index 9e1298f1da4..57a560f8fee 100644 --- a/e2e/components/DataTable.test.ts +++ b/e2e/components/DataTable.test.ts @@ -67,7 +67,7 @@ test.describe('DataTable', () => { await page.screenshot({ mask: await page .locator('td', { - has: page.locator('relative-time'), + has: page.locator('relative-time, time[data-component="RelativeTime"]'), }) .all(), }), diff --git a/package-lock.json b/package-lock.json index 4ccd4102b8a..e75e48f8041 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3902,17 +3902,17 @@ "version": "2.1.1", "license": "MIT" }, - "node_modules/@github/prettier-config": { - "version": "0.0.6", - "dev": true, - "license": "MIT" - }, "node_modules/@github/relative-time-element": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-5.0.0.tgz", "integrity": "sha512-L/2r0DNR/rMbmHWcsdmhtOiy2gESoGOhItNFD4zJ3nZfHl79Dx3N18Vfx/pYr2lruMOdk1cJZb4wEumm+Dxm1w==", "license": "MIT" }, + "node_modules/@github/prettier-config": { + "version": "0.0.6", + "dev": true, + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts index fcf8dcca3ab..9c53ce8df8b 100644 --- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -9,4 +9,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({ primer_react_action_list_item_gap: false, primer_react_timeline_list_semantics: false, primer_react_merged_forwarded_refs: false, + primer_react_relative_time: false, }) diff --git a/packages/react/src/RelativeTime/ExperimentalRelativeTime.tsx b/packages/react/src/RelativeTime/ExperimentalRelativeTime.tsx new file mode 100644 index 00000000000..a7674e99f35 --- /dev/null +++ b/packages/react/src/RelativeTime/ExperimentalRelativeTime.tsx @@ -0,0 +1,413 @@ +import type React from 'react' +import {forwardRef, useEffect, useMemo, useRef, useState} from 'react' + +type RelativeTimeFormat = 'auto' | 'datetime' | 'duration' | 'elapsed' | 'micro' | 'relative' +type RelativeTimeTense = 'auto' | 'past' | 'future' +type RelativeTimePrecision = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' + +export interface ExperimentalRelativeTimeUpdatedEvent extends Event { + oldText: string + newText: string + oldTitle: string + newTitle: string +} + +function createRelativeTimeUpdatedEvent( + oldText: string, + newText: string, + oldTitle: string, + newTitle: string, +): ExperimentalRelativeTimeUpdatedEvent { + return Object.assign(new Event('relative-time-updated'), {oldText, newText, oldTitle, newTitle}) +} + +export interface ExperimentalRelativeTimeProps extends Omit, 'dateTime'> { + date?: Date | null + datetime?: string + format?: RelativeTimeFormat + tense?: RelativeTimeTense + precision?: RelativeTimePrecision + threshold?: string + prefix?: string + second?: Intl.DateTimeFormatOptions['second'] + minute?: Intl.DateTimeFormatOptions['minute'] + hour?: Intl.DateTimeFormatOptions['hour'] + weekday?: Intl.DateTimeFormatOptions['weekday'] + day?: Intl.DateTimeFormatOptions['day'] + month?: Intl.DateTimeFormatOptions['month'] + year?: Intl.DateTimeFormatOptions['year'] + timeZoneName?: Intl.DateTimeFormatOptions['timeZoneName'] + noTitle?: boolean + onRelativeTimeUpdated?: ((event: ExperimentalRelativeTimeUpdatedEvent) => void) | null +} + +interface Duration { + year: number + month: number + week: number + day: number + hour: number + minute: number + second: number +} + +const units: RelativeTimePrecision[] = ['year', 'month', 'day', 'hour', 'minute', 'second'] + +function getDate(date: Date | null | undefined, datetime: string | undefined) { + if (datetime !== undefined) { + const parsed = new Date(datetime) + return Number.isNaN(parsed.getTime()) ? undefined : parsed + } + + return date && !Number.isNaN(date.getTime()) ? date : undefined +} + +function elapsedTime(date: Date, now: number, precision: RelativeTimePrecision): Duration { + const difference = date.getTime() - now + const sign = Math.sign(difference) + const seconds = Math.floor(Math.abs(difference) / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + const months = Math.floor(days / 30) + const years = Math.floor(months / 12) + const precisionIndex = units.indexOf(precision) + + return { + year: precisionIndex >= 0 ? years * sign : 0, + month: precisionIndex >= 1 ? (months - years * 12) * sign : 0, + week: 0, + day: precisionIndex >= 2 ? (days - months * 30) * sign : 0, + hour: precisionIndex >= 3 ? (hours - days * 24) * sign : 0, + minute: precisionIndex >= 4 ? (minutes - hours * 60) * sign : 0, + second: precisionIndex >= 5 ? (seconds - minutes * 60) * sign : 0, + } +} + +function getRelativeTimeUnit(duration: Duration, now: number): [number, Intl.RelativeTimeFormatUnit] { + const sign = Math.sign( + duration.year || + duration.month || + duration.week || + duration.day || + duration.hour || + duration.minute || + duration.second, + ) + let years = Math.abs(duration.year) + let months = Math.abs(duration.month) + let weeks = Math.abs(duration.week) + let days = Math.abs(duration.day) + let hours = Math.abs(duration.hour) + let minutes = Math.abs(duration.minute) + let seconds = Math.abs(duration.second) + + 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 + + if (days >= 27 || years + months + days) { + const relativeTo = new Date(now) + const currentYear = relativeTo.getFullYear() + const currentMonth = relativeTo.getMonth() + const currentDate = relativeTo.getDate() + 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)) / 86_400_000)) + 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 + } + + const rounded: Duration = { + year: years * sign, + month: months * sign, + week: weeks * sign, + day: days * sign, + hour: hours * sign, + minute: minutes * sign, + second: seconds * sign, + } + for (const unit of units) { + if (rounded[unit] !== 0) return [rounded[unit], unit] + } + + return [0, 'second'] +} + +function applyDuration(date: Date, duration: string) { + const match = duration.match( + /^([-+])?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/, + ) + if (!match) return undefined + + const sign = match[1] === '-' ? -1 : 1 + const values = match.slice(2).map(value => Number(value || 0) * sign) + const [years, months, weeks, days, hours, minutes, seconds] = values + const result = new Date(date) + + result.setUTCFullYear(result.getUTCFullYear() + years) + result.setUTCMonth(result.getUTCMonth() + months) + result.setUTCDate(result.getUTCDate() + weeks * 7 + days) + result.setUTCHours(result.getUTCHours() + hours) + result.setUTCMinutes(result.getUTCMinutes() + minutes) + result.setUTCSeconds(result.getUTCSeconds() + seconds) + + return result +} + +function isWithinThreshold(date: Date, now: number, threshold: string) { + const thresholdDate = applyDuration(new Date(now), threshold) + return thresholdDate ? Math.abs(date.getTime() - now) < Math.abs(thresholdDate.getTime() - now) : false +} + +function getDateTimeOptions({ + date, + now, + format, + second, + minute, + hour, + weekday, + day, + month, + year, + timeZoneName, +}: Pick< + ExperimentalRelativeTimeProps, + 'second' | 'minute' | 'hour' | 'weekday' | 'day' | 'month' | 'year' | 'timeZoneName' +> & { + date: Date + now: number + format: RelativeTimeFormat +}): Intl.DateTimeFormatOptions { + return { + second: second || undefined, + minute: minute || undefined, + hour: hour || undefined, + weekday: weekday || (format === 'datetime' ? 'short' : undefined), + day: day || 'numeric', + month: month || 'short', + year: year || (date.getUTCFullYear() === new Date(now).getUTCFullYear() ? undefined : 'numeric'), + timeZoneName: timeZoneName || undefined, + } +} + +function getDurationText(duration: Duration, locale: string, style: 'long' | 'narrow') { + const values = Object.fromEntries( + Object.entries(duration) + .filter(([, value]) => value !== 0) + .map(([unit, value]) => [`${unit}s`, Math.abs(value)]), + ) + + if (Object.keys(values).length === 0) values.seconds = 0 + + if (typeof Intl.DurationFormat !== 'undefined') { + return new Intl.DurationFormat(locale, {style}).format(values) + } + + const parts = Object.entries(values).map(([unit, value]) => + new Intl.NumberFormat(locale, { + style: 'unit', + unit: unit.slice(0, -1) as Intl.NumberFormatOptions['unit'], + unitDisplay: style, + }).format(value as number), + ) + return new Intl.ListFormat(locale, {style: 'short', type: 'unit'}).format(parts) +} + +function getFormattedText(props: ExperimentalRelativeTimeProps, date: Date | undefined, now: number) { + if (!date) return '' + + const { + format = 'auto', + tense = 'auto', + precision = format === 'micro' ? 'minute' : 'second', + threshold = 'P30D', + prefix = format === 'datetime' ? '' : 'on', + lang = 'en', + } = props + const duration = elapsedTime(date, now, precision) + const formatAsDuration = format === 'duration' || format === 'elapsed' || format === 'micro' + + if (formatAsDuration) { + const constrainedDuration = + (tense === 'past' && date.getTime() >= now) || (tense === 'future' && date.getTime() <= now) + ? {year: 0, month: 0, week: 0, day: 0, hour: 0, minute: 0, second: 0} + : duration + const [value, unit] = getRelativeTimeUnit(constrainedDuration, now) + + if (format === 'micro') { + const microUnit = value === 0 ? 'minute' : unit + return new Intl.NumberFormat(lang, {style: 'unit', unit: microUnit, unitDisplay: 'narrow'}).format( + Math.max(1, Math.abs(value)), + ) + } + + return getDurationText(constrainedDuration, lang, format === 'elapsed' ? 'narrow' : 'long') + } + + if ((format === 'auto' || format === 'relative') && (tense !== 'auto' || isWithinThreshold(date, now, threshold))) { + const constrainedDuration = + (tense === 'past' && date.getTime() >= now) || (tense === 'future' && date.getTime() <= now) + ? {year: 0, month: 0, week: 0, day: 0, hour: 0, minute: 0, second: 0} + : duration + const [value, unit] = getRelativeTimeUnit(constrainedDuration, now) + const relativeValue = unit === 'second' && Math.abs(value) < 10 ? 0 : value + return new Intl.RelativeTimeFormat(lang, {numeric: 'auto', style: 'long'}).format(relativeValue, unit) + } + + const formatter = new Intl.DateTimeFormat(lang, getDateTimeOptions({...props, date, now, format})) + return `${prefix} ${formatter.format(date)}`.trim() +} + +function getTitle(date: Date | undefined, lang: string) { + if (!date) return undefined + return new Intl.DateTimeFormat(lang, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }).format(date) +} + +function getUpdateInterval(date: Date | undefined, format: RelativeTimeFormat, precision: RelativeTimePrecision) { + if (!date || format === 'datetime') return undefined + if ((format === 'duration' || format === 'elapsed') && precision === 'second') return 1000 + if ((format === 'duration' || format === 'elapsed') && precision === 'minute') return 60_000 + + const difference = Math.abs(Date.now() - date.getTime()) + if (difference < 60_000) return 1000 + if (difference < 3_600_000) return 60_000 + return 3_600_000 +} + +const ExperimentalRelativeTime = forwardRef( + function ExperimentalRelativeTime( + { + children, + date: dateProp, + datetime, + format = 'auto', + tense, + precision: precisionProp, + threshold, + prefix, + second, + minute, + hour, + weekday, + day, + month, + year, + timeZoneName, + noTitle, + onRelativeTimeUpdated, + title, + ...timeProps + }, + forwardedRef, + ) { + const date = useMemo(() => getDate(dateProp, datetime), [dateProp, datetime]) + const [now, setNow] = useState(() => Date.now()) + const [hasHydrated, setHasHydrated] = useState(false) + const precision = precisionProp ?? (format === 'micro' ? 'minute' : 'second') + const formattedText = getFormattedText( + { + format, + tense, + precision, + threshold, + prefix, + second, + minute, + hour, + weekday, + day, + month, + year, + timeZoneName, + lang: timeProps.lang, + }, + date, + now, + ) + const displayedText = date && (hasHydrated || children === undefined) ? formattedText : children + const formattedTitle = title ?? (noTitle ? undefined : getTitle(date, timeProps.lang ?? 'en')) + const previous = useRef({text: displayedText?.toString() ?? '', title: formattedTitle ?? ''}) + + useEffect(() => { + setHasHydrated(true) + + const interval = getUpdateInterval(date, format, precision) + if (interval === undefined) return + + const timer = window.setInterval(() => setNow(Date.now()), interval) + return () => window.clearInterval(timer) + }, [date, format, precision]) + + useEffect(() => { + const next = {text: displayedText?.toString() ?? '', title: formattedTitle ?? ''} + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- The callback reports timer-driven updates. + if (next.text !== previous.current.text || next.title !== previous.current.title) { + onRelativeTimeUpdated?.( + createRelativeTimeUpdatedEvent(previous.current.text, next.text, previous.current.title, next.title), + ) + previous.current = next + } + }, [displayedText, formattedTitle, onRelativeTimeUpdated]) + + return ( + + ) + }, +) + +export default ExperimentalRelativeTime diff --git a/packages/react/src/RelativeTime/RelativeTime.docs.json b/packages/react/src/RelativeTime/RelativeTime.docs.json index 3e373c44e11..ece20b9dbab 100644 --- a/packages/react/src/RelativeTime/RelativeTime.docs.json +++ b/packages/react/src/RelativeTime/RelativeTime.docs.json @@ -23,18 +23,18 @@ "name": "datetime", "type": "string", "defaultValue": "", - "required": true, + "required": false, "description": "An ISO8601 time representing the time" }, { "name": "date", - "type": "string", + "type": "Date", "defaultValue": "", "description": "Instead of passing the `datetime`, it is possible to pass a full `Date` object into the `date` prop" }, { "name": "format", - "type": "'auto' | 'micro' | 'elapsed'", + "type": "'auto' | 'datetime' | 'duration' | 'elapsed' | 'micro' | 'relative'", "defaultValue": "'auto'", "description": "The format with which to display the time" }, @@ -110,44 +110,17 @@ "defaultValue": "", "description": "The format with which to render the time zone" }, - { - "name": "ref", - "type": "React.RefObject" - }, { "name": "noTitle", "type": "boolean", "defaultValue": "", "description": "Removes the `title` attribute provided on the element by default" }, - { - "name": "attributeChangedCallback", - "type": "(attrName: string, oldValue: unknown, newValue: unknown) => void", - "description": "", - "defaultValue": "" - }, - { - "name": "connectedCallback", - "type": "() => void", - "description": "", - "defaultValue": "" - }, { "name": "onRelativeTimeUpdated", - "type": "null | (event: RelativeTimeUpdatedEvent) => void", - "description": "", + "type": "(event: RelativeTimeUpdatedEvent) => void", + "description": "Called after the rendered time text or title changes", "defaultValue": "" - }, - { - "name": "update", - "type": "() => void", - "description": "", - "defaultValue": "" - }, - { - "name": "as", - "type": "React.ElementType", - "defaultValue": "\"relative-time\"" } ], "subcomponents": [] diff --git a/packages/react/src/RelativeTime/RelativeTime.features.stories.tsx b/packages/react/src/RelativeTime/RelativeTime.features.stories.tsx index c9ecb5a24e4..71a36255f41 100644 --- a/packages/react/src/RelativeTime/RelativeTime.features.stories.tsx +++ b/packages/react/src/RelativeTime/RelativeTime.features.stories.tsx @@ -7,9 +7,7 @@ const meta: Meta = { parameters: { layout: 'fullscreen', controls: { - // StoryBook infers from type info of the component which includes CE Lifecycle, - // SX props, and methods we want to otherwise ignore - exclude: /^(getFormatted.*|datetime|as|theme|forwardedAs|.*Callback|update)$/g, + exclude: /^(datetime|theme|forwardedAs|onRelativeTimeUpdated)$/g, }, }, args: { diff --git a/packages/react/src/RelativeTime/RelativeTime.stories.tsx b/packages/react/src/RelativeTime/RelativeTime.stories.tsx index b5241465cd2..8d1712f95f1 100644 --- a/packages/react/src/RelativeTime/RelativeTime.stories.tsx +++ b/packages/react/src/RelativeTime/RelativeTime.stories.tsx @@ -7,9 +7,7 @@ const meta: Meta = { parameters: { layout: 'fullscreen', controls: { - // StoryBook infers from type info of the component which includes CE Lifecycle, - // SX props, and methods we want to otherwise ignore - exclude: /^(getFormatted.*|datetime|as|theme|forwardedAs|.*Callback|update)$/g, + exclude: /^(datetime|theme|forwardedAs|onRelativeTimeUpdated)$/g, }, }, } diff --git a/packages/react/src/RelativeTime/RelativeTime.test.tsx b/packages/react/src/RelativeTime/RelativeTime.test.tsx index ba9f48430f5..cc929dc1c5e 100644 --- a/packages/react/src/RelativeTime/RelativeTime.test.tsx +++ b/packages/react/src/RelativeTime/RelativeTime.test.tsx @@ -1,49 +1,190 @@ -import {describe, expect, it} from 'vitest' -import RelativeTime from '.' -import {render} from '@testing-library/react' +import {act, render} from '@testing-library/react' +import type {ReactElement} from 'react' +import {renderToString} from 'react-dom/server' +import {afterEach, describe, expect, it, vi} from 'vitest' +import RelativeTime, {type ExperimentalRelativeTimeProps} from '.' +import {FeatureFlags} from '../FeatureFlags' import {implementsClassName} from '../utils/testing' +const emptyDateTimeOptions = { + second: '', + minute: '', + hour: '', + weekday: '', + day: '', + month: '', + year: '', + timeZoneName: '', +} as unknown as Pick< + ExperimentalRelativeTimeProps, + 'second' | 'minute' | 'hour' | 'weekday' | 'day' | 'month' | 'year' | 'timeZoneName' +> + +function renderWithReactRelativeTime(ui: ReactElement) { + return render({ui}) +} + +function renderReactRelativeTimeToString(ui: ReactElement) { + return renderToString({ui}) +} + describe('RelativeTime', () => { + afterEach(() => { + vi.useRealTimers() + }) + implementsClassName(RelativeTime) - it('renders a ', () => { + + it('preserves the custom-element output by default', () => { const {container} = render() expect(container.firstChild?.nodeName.toLowerCase()).toEqual('relative-time') }) - it('renders data-component attribute', () => { + it('preserves custom-element formatting attributes by default', () => { + const {container} = render() + + expect(container.firstChild).toHaveAttribute('format', 'micro') + expect(container.firstChild).toHaveAttribute('precision', 'week') + }) + + it('renders the React implementation when the feature flag is enabled', () => { const date = new Date('2024-03-07T12:22:48.123Z') - const {container} = render() + const {container} = renderWithReactRelativeTime() + expect(container.firstChild?.nodeName.toLowerCase()).toEqual('time') expect(container.firstChild).toHaveAttribute('data-component', 'RelativeTime') + expect(container.firstChild).toHaveAttribute('datetime', date.toISOString()) }) - it('renders a date inside', () => { - const date = new Date('2024-03-07T12:22:48.123Z') - const {container} = render() - expect(container.textContent).toEqual('Mar 7, 2024') + it('keeps legacy output for precision values not supported by the experiment', () => { + const {container} = renderWithReactRelativeTime() + + expect(container.firstChild?.nodeName.toLowerCase()).toEqual('relative-time') }) - it('renders a datetime inside', () => { - const date = new Date('2024-03-07T12:22:48.123Z') - const {container} = render() - expect(container.textContent).toEqual('Mar 7, 2024') + it('formats relative dates with tense and precision', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:23:48.123Z')) + + const {rerender, container} = renderWithReactRelativeTime( + , + ) + expect(container).toHaveTextContent('1 minute ago') + + rerender( + + + , + ) + expect(container).toHaveTextContent('now') }) - it('renders children if passed', () => { - const date = new Date('2024-03-07T12:22:48.123Z') - const {container} = render(server rendered date) - expect(container.textContent).toEqual('server rendered date') + it('uses an absolute date outside the threshold', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:22:48.123Z')) + + const {container} = renderWithReactRelativeTime( + , + ) + expect(container).toHaveTextContent('on Jan 1') }) - it('does not render no-title attribute by default', () => { - const date = new Date('2024-03-07T12:22:48.123Z') - const {container} = render() - expect(container.firstChild).not.toHaveAttribute('no-title') + it('supports datetime and micro formats', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:22:48.123Z')) + + const {rerender, container} = renderWithReactRelativeTime( + , + ) + expect(container).toHaveTextContent('1m') + + rerender( + + + , + ) + expect(container).toHaveTextContent('Mar 7') }) - it('adds no-title attribute if noTitle={true}', () => { - const date = new Date('2024-03-07T12:22:48.123Z') - const {container} = render() - expect(container.firstChild).toHaveAttribute('no-title') + it('treats empty date-time formatting options as unset', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:22:48.123Z')) + + const {container} = renderWithReactRelativeTime( + , + ) + + expect(container).toHaveTextContent('Thu, Mar 7') + expect(container.firstChild).not.toHaveAttribute('timezonename') + }) + + it('formats elapsed durations', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:22:48.123Z')) + + const {container} = renderWithReactRelativeTime( + , + ) + + expect(container).toHaveTextContent('1h 2m') + }) + + it('updates relative text as time passes', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:22:48.123Z')) + const {container} = renderWithReactRelativeTime() + + expect(container).toHaveTextContent('now') + act(() => vi.advanceTimersByTime(10_000)) + expect(container).toHaveTextContent('10 seconds ago') + }) + + it('notifies when the formatted text updates', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:22:48.123Z')) + const onRelativeTimeUpdated = vi.fn() + renderWithReactRelativeTime( + , + ) + + act(() => vi.advanceTimersByTime(10_000)) + + expect(onRelativeTimeUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + oldText: 'now', + newText: '10 seconds ago', + oldTitle: expect.any(String), + newTitle: expect.any(String), + }), + ) + }) + + it('honors noTitle', () => { + const {container} = renderWithReactRelativeTime( + , + ) + expect(container.firstChild).not.toHaveAttribute('title') + }) + + it('renders formatted text during server rendering', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-03-07T12:23:48.123Z')) + + const markup = renderReactRelativeTimeToString() + + expect(markup).toMatch(/