Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions l10n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,6 @@ msgstr ""
msgid "Frequently used"
msgstr ""

#. TRANSLATORS: This refers to global timezones in the timezone picker
msgid "Global"
msgstr ""

msgid "Go back to the list"
msgstr ""

Expand Down Expand Up @@ -416,9 +412,6 @@ msgstr ""
msgid "Search emoji"
msgstr ""

msgid "Search for timezone"
msgstr ""

msgid "Search results"
msgstr ""

Expand Down
19 changes: 0 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@
"@nextcloud/logger": "^3.0.2",
"@nextcloud/router": "^3.0.1",
"@nextcloud/sharing": "^0.3.0",
"@nextcloud/timezones": "^1.0.0",
"@vuepic/vue-datepicker": "^11.0.2",
"@vueuse/components": "^13.9.0",
"@vueuse/core": "^13.9.0",
Expand Down
198 changes: 104 additions & 94 deletions src/components/NcTimezonePicker/NcTimezonePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
export default {
data() {
return {
tz: 'Hawaiian Standard Time',
tz: 'Europe/Berlin',
}
},
}
Expand All @@ -25,21 +25,27 @@
</docs>

<script setup lang="ts">
import type {
IContinent,
IRegion,
ITimezone,
} from '@nextcloud/timezones'

import {
getReadableTimezoneName,
getSortedTimezoneList,
} from '@nextcloud/timezones'
import { getCanonicalLocale } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcSelect from '../NcSelect/NcSelect.vue'
import { t } from '../../l10n.ts'
import { createElementId } from '../../utils/createElementId.ts'
import NcSelect from '../NcSelect/index.js'
import getTimezoneManager from './timezoneDataProviderService.js'
import { getTimezones } from './timezoneUtils.ts'

export type ITimezone = {
/**
* Time zone ID in IANA format, e.g. "Europe/Berlin", or "floating" for a time independent of timezone, or a custom timezone ID
*/
timezoneId: string
/**
* Localized label of the timezone, e.g. "Central European Standard Time"
*/
label: string
/**
* Continent the timezone if any, e.g. "Europe" (not localized)
*/
continent: string
}

/**
* The selected timezone.
Expand All @@ -63,109 +69,113 @@
uid: createElementId(),
})

const selectedTimezone = computed({
set(timezone: IRegion) {
modelValue.value = timezone.timezoneId
},
get(): IRegion {
for (const additionalTimezone of props.additionalTimezones) {
if (additionalTimezone.timezoneId === modelValue.value) {
return {
cities: [],
...additionalTimezone,
}
}
}

return {
label: getReadableTimezoneName(modelValue.value),
timezoneId: modelValue.value,
cities: [],
}
},
const formattedAdditionalTimezones = computed(() => {
return props.additionalTimezones.map(({ timezoneId, label }) => ({
timezoneId,
label,
offset: 0,
localeOffset: '',
time: '',
}))
})

const options = computed(() => {
const timezoneManager = getTimezoneManager()
const timezoneList: IContinent[] = getSortedTimezoneList(
timezoneManager.listAllTimezones(),
props.additionalTimezones,
t('Global'), // TRANSLATORS: This refers to global timezones in the timezone picker
)
/**
* Since NcSelect does not support groups,
* we create an object with the grouped timezones and continent labels.
*
* NOTE for now we are removing the grouping from the fields to fix an accessibility issue
* in the future, other options can be introduced to better display the different areas
*/
const timezonesGrouped: IRegion[] = []
for (const group of Object.values(timezoneList)) {
// Add an entry as group label
// const continent = `tz-group__${group.continent}`
// timezonesGrouped.push({
// label: group.continent,
// continent,
// timezoneId: continent,
// regions: group.regions,
// })
timezonesGrouped.push(...group.regions)
}
return timezonesGrouped
const timezones = getTimezones()
const now = new Date()
console.time('Adding current time to timezones')

Check failure on line 85 in src/components/NcTimezonePicker/NcTimezonePicker.vue

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
const result = timezones.map((option) => ({
...option,
time: now.toLocaleString(getCanonicalLocale(), { timeZone: option.timezoneId, hour: '2-digit', minute: '2-digit' }),
}))
console.timeEnd('Adding current time to timezones')

Check failure on line 90 in src/components/NcTimezonePicker/NcTimezonePicker.vue

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
result.unshift(...formattedAdditionalTimezones.value)
return result
})

/**
* Returns whether this is a continent label,
* or an actual timezone. Continent labels are not selectable.
*
* @param option The option
*/
function isSelectable(option: IRegion): boolean {
return !option.timezoneId.startsWith('tz-group__')
}

/**
* Function to filter the timezone list.
* We search in the timezoneId, so both continent and region names can be matched.
* NcSelect's filterBy prop to search timezone by any option property
*
* @param option - The timezone option
* @param label - The label of the timezone
* @param search - The search string
*/
function filterBy(option: IContinent | IRegion, label: string, search: string): boolean {
// We split the search term in case one searches "<continent> <region>".
const terms = search.trim().split(' ')

// For the continent labels, we have to check if one region matches every search term.
if ('continent' in option) {
return option.regions.some((region) => {
return matchTimezoneId(region.timezoneId, terms)
})
}

// For a region, every search term must be found.
return matchTimezoneId(option.timezoneId, terms)
function filterBy(option: ITimezone, label: string, search: string): boolean {
const terms = search.trim().split(/\s+/)
const values = Object.values(option)
return terms.every((term) => {
return values.some((value) => value.toLowerCase().includes(term.toLowerCase()))
})
}

/**
* @param timezoneId - The timezone id to check
* @param terms - Terms to validate
*/
function matchTimezoneId(timezoneId: string, terms: string[]): boolean {
return terms.every((term) => timezoneId.toLowerCase().includes(term.toLowerCase()))
}
// Getting all the timezones with the current user language takes around 250ms on the first run
// To prevent the lag on the first open, calculating them in an idle callback
// TODO: maybe it is better to load it on open? More laggy, but doesn't load the page when not needed
requestIdleCallback(() => {
getTimezones()
})
</script>

<template>
<NcSelect
v-model="selectedTimezone"
v-model="modelValue"
:aria-label-combobox="t('Search for timezone')"
:clearable="false"
:filter-by
:multiple="false"
:options
:placeholder="t('Type to search time zone')"
:selectable="isSelectable"
:uid
label="label" />
:reduce="(option) => option.timezoneId"
label="label">
<template #option="option">
<span :class="$style.timezoneOption">
<span :class="$style.timezoneOption__row">
<span>{{ option.label }}</span>
<span :class="$style.timezoneOption__time">{{ option.time }}</span>
</span>
<span :class="[$style.timezoneOption__row, $style.timezoneOption__subline]">
<span :class="$style.timezoneOption__id">{{ option.timezoneId }}</span>
<span :class="$style.timezoneOption__offset">{{ option.localeOffset }}</span>
</span>
</span>
</template>
</NcSelect>
</template>

<style lang="scss" module>
/* Dirty hack to override the padding of vue-select options */
li:has(> .timezoneOption) {
padding: calc(1 * var(--default-grid-baseline));
}

.timezoneOption {
display: flex;
flex-direction: column;
white-space: break-spaces;
}

.timezoneOption__row {
display: flex;
justify-content: space-between;
gap: calc(1 * var(--default-grid-baseline));
}

.timezoneOption__subline {
color: var(--color-text-maxcontrast);
font-size: var(--font-size-small, 13px);
}

.timezoneOption__offset,
.timezoneOption__time {
white-space: nowrap;
}

.timezoneOption__time {
font-size: var(--font-size-small, 13px);
}

.timezoneOption__id {
/* Timezone ID can be long without spaces, like America/North_Dakota/New_Salem */
overflow-wrap: anywhere;
}
</style>
22 changes: 0 additions & 22 deletions src/components/NcTimezonePicker/timezoneDataProviderService.ts

This file was deleted.

63 changes: 63 additions & 0 deletions src/components/NcTimezonePicker/timezoneUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { getLanguage } from '@nextcloud/l10n'
import { memoize } from '../../utils/utils.ts'

const now = new Date()

/**
* Convert timezone ID in IANA format (e.g. "Europe/Berlin") to specified format via Intl.DateTimeFormat
*
* @param timeZone - IANA timezone ID (e.g. "Europe/Berlin")
* @param timeZoneName - Intl.DateTimeFormatOptions['timeZoneName']
* @param lang - Language code (e.g. 'en'), defaults to the current user's language
*/
export function formatTimezone(timeZone: string, timeZoneName: NonNullable<Intl.DateTimeFormatOptions['timeZoneName']>, lang: string = getLanguage()): string | undefined {
return new Intl.DateTimeFormat(lang, { timeZone, timeZoneName })
.formatToParts(now)
.find((part) => part.type === 'timeZoneName')
?.value
}

/**
* Get offset in ms for a given timezone ID in IANA format (e.g. "Europe/Berlin")
*
* @param timeZone - IANA timezone ID (e.g. "Europe/Berlin")
* @return - Offset in milliseconds (e.g. 3600000 for GMT+01:00)
*/
const getTimezoneOffset = memoize((timeZone: string): number => {
// 'en-US' gives predictable GMT+00:00 or GMT+00:00:00 format
const gmt = formatTimezone(timeZone, 'longOffset', 'en-US')
const [isMatched, sign, h = '0', m = '0', s = '0'] = gmt?.match(/GMT([+-])(\d+):(\d+)(?::(\d+))?/) ?? []
if (!isMatched) {
return 0
}
return (sign === '+' ? 1 : -1) * (parseInt(h) * 3600 + parseInt(m) * 60 + parseInt(s)) * 1000
})

/**
* Get a list of supported IANA timezone IDs (e.g. "Europe/Berlin") with human-readable labels,
* excluding Etc/* administrative zones not used by users (see: https://en.wikipedia.org/wiki/Tz_database#Areas)
*/
export const getTimezones = memoize(() => {
const zones = Intl.supportedValuesOf('timeZone')
.filter((tz) => !tz.startsWith('Etc/'))
console.time('Adding formatted timezone labels')

Check failure on line 48 in src/components/NcTimezonePicker/timezoneUtils.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
const result = zones.map((timezoneId) => ({
timezoneId,
label: formatTimezone(timezoneId, 'shortGeneric'), // Alternative: longGeneric, but too long our select design..
offset: getTimezoneOffset(timezoneId),
localeOffset: formatTimezone(timezoneId, 'shortOffset'),
}))
console.timeEnd('Adding formatted timezone labels')

Check failure on line 55 in src/components/NcTimezonePicker/timezoneUtils.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
// Sort by offset first, then by name
result.sort((a, b) => a.offset - b.offset || a.timezoneId.localeCompare(b.timezoneId))
return result
})

// See:
// https://play.vuejs.org/#eNqdVdtO20AQ/ZWpX+yoiRNEpaooCWoLregDoIa2EnUfjD1ODOu1tbsOl5B/78xu7IQQqFQpsnZnzlzOXDYL72NVhfMavQNvqBOVVwY0mroaRzIvqlIZWIDCDJaQqbIAn6B+JCOZlFIbELGcahjBbx+l3wU/Rf6i5q+q+Zsp/88mmsDkLmB8h/1ktUxMXkqYopnUFUfE9CIv8KGUqIMOLAhlFKWkJJxII0LdoH7GokZ9Rs4M4S8Jb12ayIRZLgyqwDzAaAxvzEOoTayM/pWbWeAfm6TvdxqoJl9BEHfhqsNgSqOJfpZlVIog7kBvh5jgj48Qh6JMYoGfy6KKFZKU/C63iW2ZNumuyLniTAtDtclKVcQtvkVSIUUpp87ellj2fkwcXWdeWtXEqNzWmLwdhlrkCQb7NlH/ktvWFFLiLRzFBoNBJ5xzGamKTLOV+3sf3g96gz36XQwGB/YXDgYDH94+CbVhvs37RSrN6TQu6MZD8bTLnITtNGfC9l+sp4CRXRrH3Z5g2TbfBS7PueXBBtX1dMg0CKhfxracD6G5rxBGoxG0w8ROm4E6dDRXFF3F7YByqXcPbiSHfbdQtEp0MVhUgvKgG8BQo8DEwLxXlCmKUeQxucizSlKXlS3hvEdcSJmTMeTSrVuLAlgswKqWlJa16ztDF6PvgriLia8Ex2Y2Q6NaF0MzG+sZJe9ma9inu0MxbjZuGMHJ0TOdtfuKElWePFPyvO4UPjegIye0Sm3NOmXOtpyRBwc3eG+FG/yHJh1TDbYmLU9pQTY40b64IVsuKVK6kZC1pjC7FS+6XTH4h99d5sz/P81eDdpWkA6rRtNpPXFe1zOaxjbLp+G1LiW99rRxAJGX0MOVC1RndnCo0Ae8i1zcyIuFKG+/WZlRNXYbeTLD5GaH/FrfsSzyzhVqVHOMvFZH7y8tilMfT07xjs6tkpagFoR+RfkddSlqztHBPtUypbQ3cDbbE/ufRQ/ThT6+Myh1Q4oTZeTS4iOP/sf4zX6J+jrd/fCdtaPd95Z/AZWWeP8=
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
Loading
Loading