Time column helpers
Eight helpers that build a time picker's columns. They decide whether the reader sees a 12- or 24-hour clock, and in which order.
<script setup lang="ts">
import type { DayPeriod } from '@shared/utils/timeColumns'
import {
dayPeriodLabel,
formatHourCell,
formatMinuteCell,
formatTime,
hourCells,
periodOf,
usesHour12,
withPeriod,
} from '@shared/utils/timeColumns'
import { Badge } from '@shared/components/ui/badge'
import { Button } from '@shared/components/ui/button'
import { computed, ref } from 'vue'
const TAGS = ['en-GB', 'en-US', 'de-DE', 'ar-EG', 'tr-TR']
const tag = ref('en-US')
const hour = ref(14)
const minute = ref(30)
const period = computed<DayPeriod>(() => periodOf(hour.value))
const cells = computed(() => hourCells(tag.value, period.value))
const minutes = [0, 15, 30, 45]
function setPeriod(next: DayPeriod): void {
hour.value = withPeriod(hour.value % 12, next)
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<Button
v-for="code in TAGS"
:key="code"
size="sm"
variant="outline"
:aria-pressed="tag === code"
@click="tag = code"
>
{{ code }}
</Button>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
usesHour12 → {{ usesHour12(tag) }}
</Badge>
<Badge variant="outline">
24-hour value → {{ hour }}:{{ String(minute).padStart(2, '0') }}
</Badge>
<Badge>
{{ formatTime(tag, hour, minute) }}
</Badge>
</div>
<div class="flex gap-3">
<div class="max-h-56 w-20 overflow-y-auto rounded-lg border border-border p-1">
<button
v-for="cell in cells"
:key="cell"
type="button"
class="block w-full rounded px-2 py-1 text-center sa-text-sm"
:class="hour === cell ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'"
:aria-pressed="hour === cell"
@click="hour = cell"
>
{{ formatHourCell(tag, cell) }}
</button>
</div>
<div class="w-20 rounded-lg border border-border p-1">
<button
v-for="cell in minutes"
:key="cell"
type="button"
class="block w-full rounded px-2 py-1 text-center sa-text-sm"
:class="minute === cell ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'"
:aria-pressed="minute === cell"
@click="minute = cell"
>
{{ formatMinuteCell(tag, cell) }}
</button>
</div>
<div v-if="usesHour12(tag)" class="flex w-20 flex-col gap-1 rounded-lg border border-border p-1">
<button
v-for="p in (['am', 'pm'] as DayPeriod[])"
:key="p"
type="button"
class="rounded px-2 py-1 sa-text-sm"
:class="period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'"
:aria-pressed="period === p"
@click="setPeriod(p)"
>
{{ dayPeriodLabel(tag, p) }}
</button>
</div>
</div>
<p class="sa-text-xs text-muted-foreground">
The hour column lists 1…11 then 12 on a 12-hour locale, and 0…23 on a 24-hour one. The values behind it are always 24-hour.
</p>
</div>
</template>Import
import {
dayPeriodLabel,
formatHourCell,
formatMinuteCell,
formatTime,
hourCells,
periodOf,
usesHour12,
withPeriod,
} from '@shared/utils/timeColumns'Every one takes a BCP-47 tag as its first argument, except periodOf and withPeriod, which are pure arithmetic.
usesHour12
function usesHour12(tag: string): booleanWhether this locale writes a 12-hour clock. Every shipped locale resolves to h12 or h23, so the answer is never ambiguous.
hourCells
function hourCells(tag: string, period: DayPeriod): number[]
type DayPeriod = 'am' | 'pm'The 24-hour hours the column lists, in display order.
A 12-hour locale gets 1…11 and then 12 — the order on a clock face, so midnight and noon land on the "12" row where a reader expects them. A 24-hour locale gets 0…23 and ignores period.
The values are always 24-hour, so nothing downstream has to convert; only the labels change.
formatHourCell and formatMinuteCell
function formatHourCell(tag: string, hour: number): string
function formatMinuteCell(tag: string, minute: number): stringOne cell's label, in the locale's own digits and padding.
formatTime
function formatTime(tag: string, hour: number, minute: number): stringThe whole value, as the locale writes it.
dayPeriodLabel
function dayPeriodLabel(tag: string, period: DayPeriod): string"AM" and "PM" in the locale's own words — ص and م in Arabic.
It is read off 09:00 and 21:00, not off midnight and noon. Some locales name those two hours specifically, and a label meaning "noon" is wrong on a button that selects an entire half-day.
periodOf and withPeriod
function periodOf(hour: number): DayPeriod
function withPeriod(hour: number, period: DayPeriod): numberperiodOf(14) is 'pm'. withPeriod(2, 'pm') is 14. The pair that moves a selection between the two columns without either one having to know the clock.
Building a column
<script setup lang="ts">
import type { DayPeriod } from '@shared/utils/timeColumns'
import { localeTag } from '@shared/utils/intl'
import {
dayPeriodLabel,
formatHourCell,
hourCells,
periodOf,
usesHour12,
withPeriod,
} from '@shared/utils/timeColumns'
import { computed, ref } from 'vue'
const tag = computed(() => localeTag())
const hour = ref(14)
const period = computed<DayPeriod>(() => periodOf(hour.value))
const cells = computed(() => hourCells(tag.value, period.value))
function setPeriod(next: DayPeriod) {
hour.value = withPeriod(hour.value % 12, next)
}
</script>
<template>
<div class="flex gap-4">
<ul>
<li v-for="cell in cells" :key="cell">
<button type="button" :aria-pressed="hour === cell" @click="hour = cell">
{{ formatHourCell(tag, cell) }}
</button>
</li>
</ul>
<div v-if="usesHour12(tag)" class="flex flex-col">
<button type="button" @click="setPeriod('am')">
{{ dayPeriodLabel(tag, 'am') }}
</button>
<button type="button" @click="setPeriod('pm')">
{{ dayPeriodLabel(tag, 'pm') }}
</button>
</div>
</div>
</template>Related
- Time picker — the component built on these.
- Date and time picker — the other consumer.
- Intl helpers —
localeTag, for the first argument.