Skip to content

Instantly share code, notes, and snippets.

@lhermann
Created January 29, 2024 09:43
Show Gist options
  • Save lhermann/e10b6ec73f2752b4f2aeec28c08a8e62 to your computer and use it in GitHub Desktop.
Save lhermann/e10b6ec73f2752b4f2aeec28c08a8e62 to your computer and use it in GitHub Desktop.
Vue Timezone Picker
<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>
<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