mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:29:32 -05:00 
			
		
		
		
	feat(wip): add Combobox component for timezone picker (#6154)
* add initial Combobox * add basic input to Combobox * add search functionality * adjust styling * add Combobox icon and adjust styling * styling * refactored * refactored * better display of timezone * fix: clicks * fix: eslint --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		
							parent
							
								
									64ab09bbb6
								
							
						
					
					
						commit
						c4b8c853bc
					
				@ -10,48 +10,51 @@
 | 
			
		||||
  import { createEventDispatcher } from 'svelte';
 | 
			
		||||
  import { DateTime } from 'luxon';
 | 
			
		||||
  import ConfirmDialogue from './confirm-dialogue.svelte';
 | 
			
		||||
  import Dropdown from '../elements/dropdown.svelte';
 | 
			
		||||
  import Combobox from './combobox.svelte';
 | 
			
		||||
  export let initialDate: DateTime = DateTime.now();
 | 
			
		||||
 | 
			
		||||
  interface ZoneOption {
 | 
			
		||||
    zone: string;
 | 
			
		||||
    offset: string;
 | 
			
		||||
  }
 | 
			
		||||
  type ZoneOption = {
 | 
			
		||||
    /**
 | 
			
		||||
     * Timezone name
 | 
			
		||||
     *
 | 
			
		||||
     * e.g. Europe/Berlin
 | 
			
		||||
     */
 | 
			
		||||
    label: string;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Timezone offset
 | 
			
		||||
     *
 | 
			
		||||
     * e.g. UTC+01:00
 | 
			
		||||
     */
 | 
			
		||||
    value: string;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const timezones: ZoneOption[] = Intl.supportedValuesOf('timeZone').map((zone: string) => ({
 | 
			
		||||
    zone,
 | 
			
		||||
    offset: 'UTC' + DateTime.local({ zone }).toFormat('ZZ'),
 | 
			
		||||
    label: zone + ` (${DateTime.local({ zone }).toFormat('ZZ')})`,
 | 
			
		||||
    value: 'UTC' + DateTime.local({ zone }).toFormat('ZZ'),
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  const initialOption = timezones.find((item) => item.offset === 'UTC' + initialDate.toFormat('ZZ'));
 | 
			
		||||
  const initialOption = timezones.find((item) => item.value === 'UTC' + initialDate.toFormat('ZZ'));
 | 
			
		||||
 | 
			
		||||
  let selectedOption = {
 | 
			
		||||
    label: initialOption?.label || '',
 | 
			
		||||
    value: initialOption?.value || '',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm");
 | 
			
		||||
  let selectedTimezone = initialOption?.offset || null;
 | 
			
		||||
  let disabled = false;
 | 
			
		||||
 | 
			
		||||
  let searchQuery = '';
 | 
			
		||||
  let filteredTimezones: ZoneOption[] = timezones;
 | 
			
		||||
 | 
			
		||||
  const updateSearchQuery = (event: Event) => {
 | 
			
		||||
    searchQuery = (event.target as HTMLInputElement).value;
 | 
			
		||||
    filterTimezones();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const filterTimezones = () => {
 | 
			
		||||
    filteredTimezones = timezones.filter((timezone) => timezone.zone.toLowerCase().includes(searchQuery.toLowerCase()));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const dispatch = createEventDispatcher<{
 | 
			
		||||
    cancel: void;
 | 
			
		||||
    confirm: string;
 | 
			
		||||
  }>();
 | 
			
		||||
 | 
			
		||||
  const handleCancel = () => dispatch('cancel');
 | 
			
		||||
 | 
			
		||||
  const handleConfirm = () => {
 | 
			
		||||
    let date = DateTime.fromISO(selectedDate);
 | 
			
		||||
    if (selectedTimezone != null) {
 | 
			
		||||
      date = date.setZone(selectedTimezone, { keepLocalTime: true }); // Keep local time if not it's really confusing
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    date = date.setZone(selectedOption.value, { keepLocalTime: true }); // Keep local time if not it's really confusing
 | 
			
		||||
 | 
			
		||||
    const value = date.toISO();
 | 
			
		||||
    if (value) {
 | 
			
		||||
@ -65,34 +68,6 @@
 | 
			
		||||
      event.stopPropagation();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let isDropdownOpen = false;
 | 
			
		||||
  let isSearching = false;
 | 
			
		||||
 | 
			
		||||
  const onSearchFocused = () => {
 | 
			
		||||
    isSearching = true;
 | 
			
		||||
 | 
			
		||||
    openDropdown();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onSearchBlurred = () => {
 | 
			
		||||
    isSearching = false;
 | 
			
		||||
 | 
			
		||||
    closeDropdown();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const openDropdown = () => {
 | 
			
		||||
    isDropdownOpen = true;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const closeDropdown = () => {
 | 
			
		||||
    isDropdownOpen = false;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelectTz = (item: ZoneOption) => {
 | 
			
		||||
    selectedTimezone = item.offset;
 | 
			
		||||
    closeDropdown();
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div role="presentation" on:keydown={handleKeydown}>
 | 
			
		||||
@ -118,29 +93,7 @@
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="flex flex-col w-full mt-2">
 | 
			
		||||
        <label for="timezone">Timezone</label>
 | 
			
		||||
 | 
			
		||||
        <div class="relative">
 | 
			
		||||
          <input
 | 
			
		||||
            class="text-sm my-4 w-full bg-gray-200 p-3 rounded-lg dark:text-white dark:bg-gray-600"
 | 
			
		||||
            id="timezoneSearch"
 | 
			
		||||
            type="text"
 | 
			
		||||
            placeholder="Search timezone..."
 | 
			
		||||
            bind:value={searchQuery}
 | 
			
		||||
            on:input={updateSearchQuery}
 | 
			
		||||
            on:focus={onSearchFocused}
 | 
			
		||||
            on:blur={onSearchBlurred}
 | 
			
		||||
          />
 | 
			
		||||
          <Dropdown
 | 
			
		||||
            class="h-[400px]"
 | 
			
		||||
            selectedOption={initialOption}
 | 
			
		||||
            options={filteredTimezones}
 | 
			
		||||
            render={(item) => (item ? `${item.zone} (${item.offset})` : '(not selected)')}
 | 
			
		||||
            on:select={({ detail: item }) => handleSelectTz(item)}
 | 
			
		||||
            controlable={true}
 | 
			
		||||
            bind:showMenu={isDropdownOpen}
 | 
			
		||||
            on:click-outside={isSearching ? null : closeDropdown}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <Combobox bind:selectedOption options={timezones} placeholder="Search timezone..." />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </ConfirmDialogue>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										83
									
								
								web/src/lib/components/shared-components/combobox.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								web/src/lib/components/shared-components/combobox.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,83 @@
 | 
			
		||||
<script lang="ts" context="module">
 | 
			
		||||
  // Necessary for eslint
 | 
			
		||||
  /* eslint-disable @typescript-eslint/no-explicit-any */
 | 
			
		||||
  type T = any;
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" generics="T">
 | 
			
		||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
			
		||||
  import { clickOutside } from '$lib/utils/click-outside';
 | 
			
		||||
  import { mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
 | 
			
		||||
 | 
			
		||||
  type ComboBoxOption = {
 | 
			
		||||
    label: string;
 | 
			
		||||
    value: T;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export let options: ComboBoxOption[] = [];
 | 
			
		||||
  export let selectedOption: ComboBoxOption;
 | 
			
		||||
  export let placeholder = '';
 | 
			
		||||
 | 
			
		||||
  let isOpen = false;
 | 
			
		||||
  let searchQuery = '';
 | 
			
		||||
 | 
			
		||||
  $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
 | 
			
		||||
 | 
			
		||||
  let handleClick = () => {
 | 
			
		||||
    searchQuery = '';
 | 
			
		||||
    isOpen = !isOpen;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let handleOutClick = () => {
 | 
			
		||||
    searchQuery = '';
 | 
			
		||||
    isOpen = false;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let handleSelect = (option: ComboBoxOption) => {
 | 
			
		||||
    selectedOption = option;
 | 
			
		||||
    isOpen = false;
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="relative" use:clickOutside on:outclick={handleOutClick}>
 | 
			
		||||
  <button
 | 
			
		||||
    class="text-sm text-left w-full bg-gray-200 p-3 rounded-lg dark:text-white dark:bg-gray-600 dark:hover:bg-gray-500 transition-all"
 | 
			
		||||
    on:click={handleClick}
 | 
			
		||||
    >{selectedOption.label}
 | 
			
		||||
    <div class="absolute right-0 top-0 h-full flex px-4 justify-center items-center content-between">
 | 
			
		||||
      <Icon path={mdiUnfoldMoreHorizontal} />
 | 
			
		||||
    </div>
 | 
			
		||||
  </button>
 | 
			
		||||
 | 
			
		||||
  {#if isOpen}
 | 
			
		||||
    <div
 | 
			
		||||
      class="absolute w-full top-full mt-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-900"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="relative border-b flex">
 | 
			
		||||
        <div class="absolute inset-y-0 left-0 flex items-center pl-3">
 | 
			
		||||
          <div class="dark:text-immich-dark-fg/75">
 | 
			
		||||
            <button class="flex items-center">
 | 
			
		||||
              <Icon path={mdiMagnify} />
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- svelte-ignore a11y-autofocus -->
 | 
			
		||||
        <input bind:value={searchQuery} autofocus {placeholder} class="ml-9 grow bg-transparent py-2" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="h-64 overflow-y-auto">
 | 
			
		||||
        {#each filteredOptions as option (option.label)}
 | 
			
		||||
          <button
 | 
			
		||||
            class="block text-left w-full px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-all
 | 
			
		||||
             ${option.label === selectedOption.label ? 'bg-gray-300 dark:bg-gray-600' : ''}
 | 
			
		||||
            "
 | 
			
		||||
            class:bg-gray-300={option.label === selectedOption.label}
 | 
			
		||||
            on:click={() => handleSelect(option)}
 | 
			
		||||
          >
 | 
			
		||||
            {option.label}
 | 
			
		||||
          </button>
 | 
			
		||||
        {/each}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  {/if}
 | 
			
		||||
</div>
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user