-
-
Save jasonleow/a3c1d91386717bc7103f26f167eb38de to your computer and use it in GitHub Desktop.
Vue Timezone Picker
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<!-- Select Button --> | |
<button | |
ref="referenceRef" | |
class="flex items-center gap-3 rounded h-10 px-4" | |
:class="{ | |
'border text-black' : !props.dark, | |
'border bg-neutral-800 border-neutral-600 text-white' : props.dark, | |
'opacity-40': disabled, | |
}" | |
v-bind="$attrs" | |
:aria-expanded="open" | |
aria-haspopup="listbox" | |
:aria-controls="id" | |
:disabled="disabled" | |
@click="open ? onCloseSelect($event) : onOpenSelect($event)" | |
@keydown.down.prevent="onReferenceKeydown" | |
> | |
<span class="grow text-left"> | |
{{ currentOption?.label || 'Choose an option...' }} | |
</span> | |
<FaIcon | |
:icon="faChevronDown" | |
size="xs" | |
class="opacity-40" | |
/> | |
</button> | |
<!-- Floating Element --> | |
<div | |
v-if="open" | |
:id="id" | |
ref="floatingRef" | |
tabindex="0" | |
class="flex flex-col rounded border !m-0 ring-0 shadow-lg z-50" | |
:class="{ | |
'bg-white text-black': !props.dark, | |
'bg-neutral-800 border-neutral-600 text-white': props.dark, | |
}" | |
:style="floatingStyles" | |
@keydown="onKeydown" | |
> | |
<!-- Search --> | |
<div | |
v-if="props.fuzzysearch" | |
class="relative shrink-0 p-1" | |
> | |
<input | |
ref="searchRef" | |
v-model="search" | |
type="search" | |
class="rounded border bg-transparent h-6 w-full pl-2 pr-6 py-0 text-sm placeholder-neutral-400" | |
:class="{ | |
'border-neutral-200': !props.dark, | |
'border-neutral-600': props.dark, | |
}" | |
placeholder="Search..." | |
@keydown.space.stop | |
@keydown.enter.stop | |
> | |
<button | |
v-if="search" | |
class="absolute right-1 h-6 w-6 leading-6 rounded" | |
@click.stop="onResetSearch" | |
> | |
<FaIcon | |
:icon="faTimes" | |
size="xs" | |
class="opacity-40" | |
type="button" | |
/> | |
</button> | |
<FaIcon | |
v-else | |
:icon="faSearch" | |
size="xs" | |
class="absolute opacity-40 right-3 top-1/2 -translate-y-1/2" | |
/> | |
</div> | |
<!-- Options List --> | |
<div | |
class="grow min-h-0 p-1 overflow-y-auto" | |
role="listbox" | |
:aria-activedescendant="props.modelValue ? id + '_' + props.modelValue : ''" | |
@mouseleave="focusIndex = -1" | |
> | |
<div | |
v-for="(option, index) in filteredOptions" | |
:id="id + '_' + option.value" | |
:key="option.value" | |
role="option" | |
class="flex items-baseline rounded py-1 text-sm cursor-default" | |
:class="{ | |
'bg-blue-600 text-white': index === focusIndex && !option.disabled, | |
'opacity-40': option.disabled, | |
}" | |
:disabled="option.disabled" | |
@click="!option.disabled && onSelectOption(option.value)" | |
@mouseenter="focusIndex = index" | |
> | |
<div class="shrink-0 w-6 text-center"> | |
<FaIcon | |
v-if="option.value === props.modelValue" | |
:icon="faCheck" | |
size="xs" | |
/> | |
</div> | |
<div class="grow pr-3"> | |
<p class="font-semibold">{{ option.label }}</p> | |
<p | |
v-if="option.detail" | |
class="opacity-40 font-light" | |
> | |
{{ option.detail }} | |
</p> | |
</div> | |
</div> | |
<div | |
v-if="!filteredOptions.length" | |
class="text-sm font-light opacity-40 p-2" | |
> | |
No items found | |
</div> | |
</div> | |
</div> | |
</template> | |
<script setup> | |
import { faChevronDown, faCheck, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons' | |
import { useFloating, offset, size, autoUpdate } from '@floating-ui/vue' | |
import Fuse from 'fuse.js/min-basic' | |
import { nanoid } from 'nanoid' | |
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' | |
import _clamp from 'lodash/clamp' | |
/* | |
* Floating UI Docs: https://floating-ui.com/ | |
* Code example: https://github.com/floating-ui/floating-ui/blob/master/website/lib/components/Home/Select.js | |
* Fuse Docs: https://www.fusejs.io/ | |
*/ | |
const emit = defineEmits(['update:modelValue']) | |
const props = defineProps({ | |
modelValue: { type: [String, Number, Boolean], default: undefined }, | |
options: { type: Array, required: true }, | |
dataLabel: { type: String, default: undefined }, | |
disabled: Boolean, | |
dark: Boolean, | |
fuzzysearch: Boolean, | |
}) | |
let fuse = null | |
const fuseOptions = { | |
threshold: 0.4, | |
ignoreLocation: true, | |
keys: ['label', 'value', 'detail'], | |
} | |
const id = ref(null) | |
const open = ref(false) | |
const referenceRef = ref(null) | |
const floatingRef = ref(null) | |
const searchRef = ref(null) | |
const focusIndex = ref(-1) | |
const search = ref('') | |
const filteredOptions = ref([]) | |
const listLength = computed(() => filteredOptions.value.length) | |
const currentOption = computed(() => props.options.find((opt) => opt.value === props.modelValue)) | |
onMounted(() => { | |
id.value = 'id_' + nanoid(5) | |
}) | |
// | |
// Floating UI options | |
// | |
const { floatingStyles, isPositioned } = useFloating( | |
referenceRef, | |
floatingRef, | |
{ | |
open: open, | |
middleware: [ | |
offset(3), | |
size({ | |
apply ({ rects, elements, availableHeight }) { | |
Object.assign(elements.floating.style, { | |
maxHeight: `max(min(${availableHeight}px, 60vh), 200px)`, | |
width: `${rects.reference.width}px`, | |
}) | |
}, | |
padding: 25, | |
}), | |
], | |
whileElementsMounted: autoUpdate, | |
placement: 'bottom-start', | |
}, | |
) | |
// | |
// Select Option | |
// | |
function onSelectOption (value) { | |
emit('update:modelValue', value) | |
onCloseSelect() | |
referenceRef.value?.focus() | |
} | |
// | |
// Fussy Search | |
// | |
watch( | |
search, | |
(str) => { | |
if (fuse && str) filteredOptions.value = fuse.search(str).map(({ item }) => item) | |
else filteredOptions.value = props.options | |
}, | |
{ immediate: true }, | |
) | |
function onResetSearch () { | |
search.value = '' | |
searchRef.value.focus() | |
} | |
// | |
// Handle Open / Close and outside click | |
// | |
function onOpenSelect () { | |
focusIndex.value = props.fuzzysearch ? -1 : 0 | |
search.value = '' | |
open.value = true | |
document.addEventListener('click', onOutsideClick) | |
fuse = new Fuse(props.options, fuseOptions) | |
// Watch until floating ref is positioned | |
watch (isPositioned, () => { | |
searchRef.value?.focus() | |
}, { once: true }) | |
} | |
function onCloseSelect () { | |
open.value = false | |
document.removeEventListener('click', onOutsideClick) | |
} | |
onBeforeUnmount(() => document.removeEventListener('click', onOutsideClick)) | |
function onOutsideClick (event) { | |
if (floatingRef.value?.contains(event.target)) return | |
if (referenceRef.value?.contains(event.target)) return | |
if (open.value) onCloseSelect(event) | |
} | |
// | |
// Handle Keyboard Nav | |
// | |
function onKeydown (event) { | |
// Up/Down | |
if (event.key === 'ArrowDown') incrementIndex(1) | |
if (event.key === 'ArrowUp') incrementIndex(-1) | |
// Close | |
if (event.key === 'Escape') { | |
onCloseSelect() | |
referenceRef.value?.focus() | |
} | |
// Select | |
if ([' ', 'Enter'].includes(event.key) && focusIndex.value >= 0) { | |
const focusedOption = filteredOptions.value[focusIndex.value] | |
if (focusedOption) onSelectOption(focusedOption.value) | |
} | |
// Prevent default browser action | |
if (['ArrowUp', 'ArrowDown', ' ', 'Enter'].includes(event.key)) { | |
event.preventDefault() | |
} | |
} | |
function onReferenceKeydown (event) { | |
if (!open.value) onOpenSelect(event) | |
else onKeydown(event) | |
} | |
function incrementIndex (i) { | |
// Simple increment | |
let newIndex = _clamp(focusIndex.value + i, -2, listLength.value - 1) | |
// Skip search if disabled | |
if (!props.fuzzysearch && newIndex === -1) newIndex += i | |
// Skip disabled options | |
const newOption = filteredOptions.value[newIndex] | |
if (newOption?.disabled) newIndex = _clamp(newIndex + i, 0, listLength.value - 1) | |
focusIndex.value = newIndex | |
} | |
watch( | |
focusIndex, | |
(i) => { | |
if (i === -2) referenceRef.value?.focus() | |
if (i === -1) searchRef.value?.focus() | |
if (0 <= i && i < listLength.value ) { | |
floatingRef.value?.focus() | |
const focusId = id.value + '_' + filteredOptions.value[i]?.value | |
const optionEl = document.getElementById(focusId) | |
optionEl?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) | |
} | |
}, | |
) | |
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<AdvancedSelect | |
:model-value="props.modelValue" | |
:options="options" | |
:dark="props.dark" | |
:disabled="props.disabled" | |
fuzzysearch | |
@update:model-value="emit('update:modelValue', $event)" | |
/> | |
</template> | |
<script setup> | |
import AdvancedSelect from './AdvancedSelect.vue' | |
import timeZoneFallbackValue from '../../../utils/timeZones.json' | |
import { formatTimezone } from '@stagetimerio/timeutils' | |
const options = _getTimezoneOptions() | |
const emit = defineEmits(['update:modelValue']) | |
const props = defineProps({ | |
modelValue: { type: String, default: 'UTC' }, | |
disabled: Boolean, | |
dark: Boolean, | |
}) | |
function _getTimezoneOptions () { | |
let timezones = [] | |
try { | |
timezones = Intl.supportedValuesOf('timeZone') | |
} catch (err) { | |
timezones = timeZoneFallbackValue | |
} | |
timezones = timezones.filter(tz => tz.includes('/') && !tz.includes('GMT')) | |
timezones.unshift('UTC') | |
return timezones.map(_generateOption) | |
} | |
function _generateOption (tz) { | |
return { | |
value: tz, | |
label: formatTimezone(tz, 'city'), | |
detail: [ | |
formatTimezone(tz, 'offset'), | |
formatTimezone(tz, 'long'), | |
formatTimezone(tz, 'abbr'), | |
].join(' | '), | |
} | |
} | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment