diff --git a/resources/js/components/ui/DatePicker/DatePicker.vue b/resources/js/components/ui/DatePicker/DatePicker.vue index bdc9930e96d..055c5f2861b 100644 --- a/resources/js/components/ui/DatePicker/DatePicker.vue +++ b/resources/js/components/ui/DatePicker/DatePicker.vue @@ -211,6 +211,7 @@ const getInputLabel = (part) => { v-if="timeZoneLabel" :date="modelValue.toDate()" :additional-timezones="additionalTimezones" + :show-seconds="granularity === 'second'" side="top" > diff --git a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue index 71bcca14d94..e02975927d8 100644 --- a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue +++ b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue @@ -194,6 +194,7 @@ const hoverCardDate = computed(() => { v-if="timeZoneLabel && hoverCardDate" :date="hoverCardDate" :additional-timezones="additionalTimezones" + :show-seconds="granularity === 'second'" side="top" > diff --git a/resources/js/components/ui/TimezoneHoverCard.vue b/resources/js/components/ui/TimezoneHoverCard.vue index 0a8a2cd85ce..e732ed21d65 100644 --- a/resources/js/components/ui/TimezoneHoverCard.vue +++ b/resources/js/components/ui/TimezoneHoverCard.vue @@ -7,6 +7,8 @@ const props = defineProps({ date: { type: [String, Date, Number, Object], required: true }, /** Extra `{ timezone, label }` rows to render above the defaults. Forwarded to ``. */ additionalTimezones: { type: Array, default: () => [] }, + /** When `false`, times are displayed without seconds. Forwarded to ``. */ + showSeconds: { type: Boolean, default: true }, }); @@ -15,6 +17,6 @@ const props = defineProps({ - + diff --git a/resources/js/components/ui/Timezones.vue b/resources/js/components/ui/Timezones.vue index 08fc4a75883..a33ea09242a 100644 --- a/resources/js/components/ui/Timezones.vue +++ b/resources/js/components/ui/Timezones.vue @@ -10,6 +10,8 @@ const props = defineProps({ date: { type: [String, Date, Number, Object], required: true }, /** Extra `{ timezone, label }` rows to render above the defaults. */ additionalTimezones: { type: Array, default: () => [] }, + /** When `false`, times are displayed without seconds. */ + showSeconds: { type: Boolean, default: true }, }); const isRange = computed(() => { @@ -40,7 +42,7 @@ const formatTimeZone = (timeZone) => { const formatDateTime = (timeZone) => { const formatter = new Intl.DateTimeFormat(dateFormatter.locale, { dateStyle: isRange.value ? 'short' : 'medium', - timeStyle: 'medium', + timeStyle: props.showSeconds ? 'medium' : 'short', timeZone, }); return isRange.value ? formatter.formatRange(start.value, end.value) : formatter.format(start.value); diff --git a/resources/js/stories/Timezones.stories.ts b/resources/js/stories/Timezones.stories.ts index da2854a79c5..40d6a5df151 100644 --- a/resources/js/stories/Timezones.stories.ts +++ b/resources/js/stories/Timezones.stories.ts @@ -91,6 +91,21 @@ export const _Range: Story = { }), }; +export const _WithoutSeconds: Story = { + tags: ['!dev'], + args: { + date: exampleDate, + }, + render: (args) => ({ + components: { Timezones }, + setup() { + const date = args.date; + return { date }; + }, + template: ``, + }), +}; + const customDateCode = ` diff --git a/resources/js/stories/docs/Timezones.mdx b/resources/js/stories/docs/Timezones.mdx index 5c1f60e253b..2bd98a2a3cf 100644 --- a/resources/js/stories/docs/Timezones.mdx +++ b/resources/js/stories/docs/Timezones.mdx @@ -13,6 +13,10 @@ Displays a given instant rendered across the user's local timezone, the configur Pass a `{ start, end }` object to render a date range. The value column uses locale-aware formatting that collapses repeated parts (e.g. shared month or year). +## Seconds +Seconds are displayed by default. Set `show-seconds` to `false` when they aren't relevant, like for a date picked with `minute` granularity. + + ## Hover Card This is a convenience wrapper component that displays the timezones in a hover card. You can provide any of [HoverCard](?path=/docs/overlays-hovercard--docs)'s props. diff --git a/resources/js/tests/components/ui/DatePicker.test.js b/resources/js/tests/components/ui/DatePicker.test.js index 08f27bf91c4..55a905ed09c 100644 --- a/resources/js/tests/components/ui/DatePicker.test.js +++ b/resources/js/tests/components/ui/DatePicker.test.js @@ -2,15 +2,16 @@ import { mount } from '@vue/test-utils'; import { expect, test } from 'vitest'; import { CalendarDateTime, toZoned } from '@internationalized/date'; import DatePicker from '@/components/ui/DatePicker/DatePicker.vue'; +import TimezoneHoverCard from '@/components/ui/TimezoneHoverCard.vue'; import { config } from '@api'; window.__ = (key) => key; config.initialize({ translationLocale: 'en' }); -const makeDatePicker = (modelValue) => { +const makeDatePicker = (modelValue, granularity = 'minute') => { return mount(DatePicker, { - props: { modelValue, granularity: 'minute' }, + props: { modelValue, granularity }, global: { mocks: { $date: { locale: 'en-US' }, @@ -38,3 +39,12 @@ test('typing an hour keeps the day period of the zoned date', async () => { expect(datePicker.emitted('update:modelValue')[0][0].toString()).toBe(modelValue.set({ hour: 17 }).toString()); }); + +test.each([ + ['minute', false], + ['second', true], +])('the timezone hover card with %s granularity shows seconds: %s', (granularity, showSeconds) => { + const datePicker = makeDatePicker(toZoned(new CalendarDateTime(2024, 1, 20, 15, 30, 27), 'Europe/London'), granularity); + + expect(datePicker.findComponent(TimezoneHoverCard).props('showSeconds')).toBe(showSeconds); +}); diff --git a/resources/js/tests/components/ui/DateRangePicker.test.js b/resources/js/tests/components/ui/DateRangePicker.test.js index dcb02368e44..f51fb88a669 100644 --- a/resources/js/tests/components/ui/DateRangePicker.test.js +++ b/resources/js/tests/components/ui/DateRangePicker.test.js @@ -2,15 +2,16 @@ import { mount } from '@vue/test-utils'; import { expect, test } from 'vitest'; import { CalendarDateTime, toZoned } from '@internationalized/date'; import DateRangePicker from '@/components/ui/DateRangePicker/DateRangePicker.vue'; +import TimezoneHoverCard from '@/components/ui/TimezoneHoverCard.vue'; import { config } from '@api'; window.__ = (key) => key; config.initialize({ translationLocale: 'en' }); -const makeDateRangePicker = (modelValue) => { +const makeDateRangePicker = (modelValue, granularity = 'minute') => { return mount(DateRangePicker, { - props: { modelValue, granularity: 'minute' }, + props: { modelValue, granularity }, global: { mocks: { $date: { locale: 'en-US' }, @@ -60,3 +61,18 @@ test('day period reflects the zoned date rather than the browser timezone when o expect(dateRangePicker.get(segment('end', 'hour')).text()).toBe('4'); expect(dateRangePicker.get(segment('end', 'dayPeriod')).text()).toBe('PM'); }); + +test.each([ + ['minute', false], + ['second', true], +])('the timezone hover card with %s granularity shows seconds: %s', (granularity, showSeconds) => { + const dateRangePicker = makeDateRangePicker( + { + start: toZoned(new CalendarDateTime(2024, 1, 20, 15, 30, 27), 'Europe/London'), + end: toZoned(new CalendarDateTime(2024, 1, 21, 16, 30, 27), 'Europe/London'), + }, + granularity, + ); + + expect(dateRangePicker.findComponent(TimezoneHoverCard).props('showSeconds')).toBe(showSeconds); +}); diff --git a/resources/js/tests/components/ui/Timezones.test.js b/resources/js/tests/components/ui/Timezones.test.js new file mode 100644 index 00000000000..597d7b2fab7 --- /dev/null +++ b/resources/js/tests/components/ui/Timezones.test.js @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import Timezones from '@/components/ui/Timezones.vue'; +import DateFormatter from '@/components/DateFormatter.js'; +import { config } from '@api'; + +config.initialize({ translationLocale: 'en', displayTimezone: 'UTC' }); +DateFormatter.defaultLocale = 'en-US'; + +const makeTimezones = (props) => mount(Timezones, { props }); + +const date = '2026-05-05T12:34:27.000Z'; +const range = { start: '2026-05-05T12:34:27.000Z', end: '2026-05-08T17:30:42.000Z' }; + +test('seconds are shown by default', () => { + expect(makeTimezones({ date }).text()).toContain('12:34:27'); +}); + +test('seconds can be hidden', () => { + const text = makeTimezones({ date, showSeconds: false }).text(); + + expect(text).toContain('12:34'); + expect(text).not.toContain(':27'); +}); + +test('seconds are shown for ranges by default', () => { + const text = makeTimezones({ date: range }).text(); + + expect(text).toContain('12:34:27'); + expect(text).toContain('5:30:42'); +}); + +test('seconds can be hidden for ranges', () => { + const text = makeTimezones({ date: range, showSeconds: false }).text(); + + expect(text).toContain('12:34'); + expect(text).toContain('5:30'); + expect(text).not.toContain(':27'); + expect(text).not.toContain(':42'); +});