mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:39:37 -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 { createEventDispatcher } from 'svelte';
 | 
				
			||||||
  import { DateTime } from 'luxon';
 | 
					  import { DateTime } from 'luxon';
 | 
				
			||||||
  import ConfirmDialogue from './confirm-dialogue.svelte';
 | 
					  import ConfirmDialogue from './confirm-dialogue.svelte';
 | 
				
			||||||
  import Dropdown from '../elements/dropdown.svelte';
 | 
					  import Combobox from './combobox.svelte';
 | 
				
			||||||
  export let initialDate: DateTime = DateTime.now();
 | 
					  export let initialDate: DateTime = DateTime.now();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface ZoneOption {
 | 
					  type ZoneOption = {
 | 
				
			||||||
    zone: string;
 | 
					    /**
 | 
				
			||||||
    offset: string;
 | 
					     * 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) => ({
 | 
					  const timezones: ZoneOption[] = Intl.supportedValuesOf('timeZone').map((zone: string) => ({
 | 
				
			||||||
    zone,
 | 
					    label: zone + ` (${DateTime.local({ zone }).toFormat('ZZ')})`,
 | 
				
			||||||
    offset: 'UTC' + 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 selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm");
 | 
				
			||||||
  let selectedTimezone = initialOption?.offset || null;
 | 
					 | 
				
			||||||
  let disabled = false;
 | 
					  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<{
 | 
					  const dispatch = createEventDispatcher<{
 | 
				
			||||||
    cancel: void;
 | 
					    cancel: void;
 | 
				
			||||||
    confirm: string;
 | 
					    confirm: string;
 | 
				
			||||||
  }>();
 | 
					  }>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleCancel = () => dispatch('cancel');
 | 
					  const handleCancel = () => dispatch('cancel');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleConfirm = () => {
 | 
					  const handleConfirm = () => {
 | 
				
			||||||
    let date = DateTime.fromISO(selectedDate);
 | 
					    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();
 | 
					    const value = date.toISO();
 | 
				
			||||||
    if (value) {
 | 
					    if (value) {
 | 
				
			||||||
@ -65,34 +68,6 @@
 | 
				
			|||||||
      event.stopPropagation();
 | 
					      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>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div role="presentation" on:keydown={handleKeydown}>
 | 
					<div role="presentation" on:keydown={handleKeydown}>
 | 
				
			||||||
@ -118,29 +93,7 @@
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div class="flex flex-col w-full mt-2">
 | 
					      <div class="flex flex-col w-full mt-2">
 | 
				
			||||||
        <label for="timezone">Timezone</label>
 | 
					        <label for="timezone">Timezone</label>
 | 
				
			||||||
 | 
					        <Combobox bind:selectedOption options={timezones} placeholder="Search timezone..." />
 | 
				
			||||||
        <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>
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </ConfirmDialogue>
 | 
					  </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