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()
+})
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
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)
+ })
+ })
+})