From 22dfdcf99f81e6c605e5318a7793bd7d6abc65fe Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 26 Jan 2026 22:54:57 +0000 Subject: [PATCH] feat: allow showing dates in relative time --- app/components/AppHeader.vue | 16 ++- app/components/DateTime.vue | 65 ++++++++++++ app/components/PackageCard.vue | 2 +- app/components/PackageVersions.vue | 12 +-- app/components/SettingsMenu.vue | 116 +++++++++++++++++++++ app/composables/useSettings.ts | 44 ++++++++ app/pages/[...package].vue | 6 +- test/nuxt/components.spec.ts | 54 ++++++++++ test/nuxt/components/DateTime.spec.ts | 145 ++++++++++++++++++++++++++ 9 files changed, 441 insertions(+), 19 deletions(-) create mode 100644 app/components/DateTime.vue create mode 100644 app/components/SettingsMenu.vue create mode 100644 app/composables/useSettings.ts create mode 100644 test/nuxt/components/DateTime.spec.ts diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue index d3ab19aa5d..869b2d7cbb 100644 --- a/app/components/AppHeader.vue +++ b/app/components/AppHeader.vue @@ -26,7 +26,7 @@ withDefaults( diff --git a/app/components/DateTime.vue b/app/components/DateTime.vue new file mode 100644 index 0000000000..096aee6e45 --- /dev/null +++ b/app/components/DateTime.vue @@ -0,0 +1,65 @@ + + + diff --git a/app/components/PackageCard.vue b/app/components/PackageCard.vue index 38b10891ba..7b1f628e72 100644 --- a/app/components/PackageCard.vue +++ b/app/components/PackageCard.vue @@ -76,7 +76,7 @@ const emit = defineEmits<{
Updated
-
-
-
-
- +import { onKeyStroke, onClickOutside } from '@vueuse/core' + +const { settings } = useSettings() + +const isOpen = ref(false) +const menuRef = useTemplateRef('menuRef') +const triggerRef = useTemplateRef('triggerRef') + +function toggle() { + isOpen.value = !isOpen.value +} + +function close() { + isOpen.value = false +} + +// Close on click outside +onClickOutside(menuRef, close, { ignore: [triggerRef] }) + +// Close on Escape +onKeyStroke('Escape', () => { + if (isOpen.value) { + close() + triggerRef.value?.focus() + } +}) + +// Open with comma key (global shortcut) +onKeyStroke(',', e => { + // Don't trigger if user is typing in an input + const target = e.target as HTMLElement + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return + } + e.preventDefault() + toggle() +}) + + + diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts new file mode 100644 index 0000000000..c82fc210fe --- /dev/null +++ b/app/composables/useSettings.ts @@ -0,0 +1,44 @@ +import type { RemovableRef } from '@vueuse/core' +import { useLocalStorage } from '@vueuse/core' + +/** + * Application settings stored in localStorage + */ +export interface AppSettings { + /** Display dates as relative (e.g., "3 days ago") instead of absolute */ + relativeDates: boolean +} + +const DEFAULT_SETTINGS: AppSettings = { + relativeDates: false, +} + +const STORAGE_KEY = 'npmx-settings' + +// Shared settings instance (singleton per app) +let settingsRef: RemovableRef | null = null + +/** + * Composable for managing application settings with localStorage persistence. + * Settings are shared across all components that use this composable. + */ +export function useSettings() { + if (!settingsRef) { + settingsRef = useLocalStorage(STORAGE_KEY, DEFAULT_SETTINGS, { + mergeDefaults: true, + }) + } + + return { + settings: settingsRef, + } +} + +/** + * Composable for accessing just the relative dates setting. + * Useful for components that only need to read this specific setting. + */ +export function useRelativeDates() { + const { settings } = useSettings() + return computed(() => settings.value.relativeDates) +} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 69fa7f57e6..62d3de032c 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -577,11 +577,7 @@ defineOgImageComponent('Package', {
Updated
- +
diff --git a/test/nuxt/components.spec.ts b/test/nuxt/components.spec.ts index f663581c97..507b6a0050 100644 --- a/test/nuxt/components.spec.ts +++ b/test/nuxt/components.spec.ts @@ -49,6 +49,7 @@ afterEach(() => { mountedContainers.length = 0 }) +import DateTime from '~/components/DateTime.vue' import AppHeader from '~/components/AppHeader.vue' import AppFooter from '~/components/AppFooter.vue' import AppTooltip from '~/components/AppTooltip.vue' @@ -82,6 +83,59 @@ import OrgTeamsPanel from '~/components/OrgTeamsPanel.vue' import CodeMobileTreeDrawer from '~/components/CodeMobileTreeDrawer.vue' describe('component accessibility audits', () => { + describe('DateTime', () => { + it('should have no accessibility violations with ISO string datetime', async () => { + const component = await mountSuspended(DateTime, { + props: { datetime: '2024-01-15T12:00:00.000Z' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with Date object', async () => { + const component = await mountSuspended(DateTime, { + props: { datetime: new Date('2024-01-15T12:00:00.000Z') }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with custom title', async () => { + const component = await mountSuspended(DateTime, { + props: { + datetime: '2024-01-15T12:00:00.000Z', + title: 'Last updated on January 15, 2024', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with dateStyle', async () => { + const component = await mountSuspended(DateTime, { + props: { + datetime: '2024-01-15T12:00:00.000Z', + dateStyle: 'medium', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with individual date parts', async () => { + const component = await mountSuspended(DateTime, { + props: { + datetime: '2024-01-15T12:00:00.000Z', + year: 'numeric', + month: 'short', + day: 'numeric', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('AppHeader', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(AppHeader) diff --git a/test/nuxt/components/DateTime.spec.ts b/test/nuxt/components/DateTime.spec.ts new file mode 100644 index 0000000000..5c5de7e692 --- /dev/null +++ b/test/nuxt/components/DateTime.spec.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import DateTime from '~/components/DateTime.vue' + +// Mock the useRelativeDates composable +const mockRelativeDates = ref(false) +vi.mock('~/composables/useSettings', () => ({ + useRelativeDates: () => mockRelativeDates, + useSettings: () => ({ + settings: ref({ relativeDates: mockRelativeDates.value }), + }), +})) + +describe('DateTime', () => { + const testDate = '2024-01-15T12:00:00.000Z' + const testDateObject = new Date('2024-06-15T10:30:00.000Z') + + beforeEach(() => { + mockRelativeDates.value = false + }) + + describe('props handling', () => { + it('accepts datetime as ISO string', async () => { + const component = await mountSuspended(DateTime, { + props: { datetime: testDate }, + }) + expect(component.html()).toContain('time') + }) + + it('accepts datetime as Date object', async () => { + const component = await mountSuspended(DateTime, { + props: { datetime: testDateObject }, + }) + expect(component.html()).toContain('time') + }) + + it('passes date-style prop to NuxtTime', async () => { + const component = await mountSuspended(DateTime, { + props: { + datetime: testDate, + dateStyle: 'medium', + }, + }) + // The component should render with the specified dateStyle + expect(component.html()).toBeTruthy() + }) + + it('passes individual date parts to NuxtTime', async () => { + const component = await mountSuspended(DateTime, { + props: { + datetime: testDate, + year: 'numeric', + month: 'short', + day: 'numeric', + }, + }) + expect(component.html()).toBeTruthy() + }) + }) + + describe('title attribute', () => { + it('uses datetime string as title by default', async () => { + const component = await mountSuspended(DateTime, { + props: { datetime: testDate }, + }) + const timeEl = component.find('time') + expect(timeEl.attributes('title')).toBe(testDate) + }) + + it('uses custom title when provided', async () => { + const customTitle = 'Custom date title' + const component = await mountSuspended(DateTime, { + props: { + datetime: testDate, + title: customTitle, + }, + }) + const timeEl = component.find('time') + expect(timeEl.attributes('title')).toBe(customTitle) + }) + + it('converts Date object to ISO string for title', async () => { + const component = await mountSuspended(DateTime, { + props: { datetime: testDateObject }, + }) + const timeEl = component.find('time') + expect(timeEl.attributes('title')).toBe(testDateObject.toISOString()) + }) + }) + + describe('relative dates setting', () => { + it('renders absolute date when relativeDates is false', async () => { + mockRelativeDates.value = false + const component = await mountSuspended(DateTime, { + props: { + datetime: testDate, + dateStyle: 'medium', + }, + }) + // Should not have the "relative" attribute behavior + // The NuxtTime component will render with date formatting + expect(component.html()).toContain('time') + }) + + it('renders with relative prop when relativeDates is true', async () => { + mockRelativeDates.value = true + const component = await mountSuspended(DateTime, { + props: { datetime: testDate }, + }) + // Component should still render a time element + expect(component.html()).toContain('time') + }) + + it('always preserves title attribute for accessibility regardless of mode', async () => { + // Test with relative dates off + mockRelativeDates.value = false + let component = await mountSuspended(DateTime, { + props: { datetime: testDate }, + }) + expect(component.find('time').attributes('title')).toBe(testDate) + + // Test with relative dates on + mockRelativeDates.value = true + component = await mountSuspended(DateTime, { + props: { datetime: testDate }, + }) + expect(component.find('time').attributes('title')).toBe(testDate) + }) + }) + + describe('SSR fallback', () => { + it('renders time element in fallback (SSR) mode', async () => { + // The ClientOnly component has a fallback slot for SSR + // This test ensures the fallback renders correctly + const component = await mountSuspended(DateTime, { + props: { + datetime: testDate, + dateStyle: 'medium', + }, + }) + // Should have a time element rendered + expect(component.find('time').exists()).toBe(true) + }) + }) +})