<svelte:options
  customElement={{
    tag: 'm-option-listbox',
    props: {
      open: { reflect: true, type: 'Boolean', attribute: 'open' },
      multiple: { reflect: true, type: 'Boolean', attribute: 'multiple' },
      search: { reflect: true, type: 'Boolean', attribute: 'search' },
      actions: { reflect: true, type: 'Boolean', attribute: 'actions' },
      checkablesections: { reflect: true, type: 'Boolean', attribute: 'checkablesections' },
    },
  }}
/>

<script lang="ts">
  /**
   * An Option Listbox is a customizable, accessible listbox component designed to power dropdowns and comboboxes with advanced value capabilities. It supports single or multiple value, optional search, grouped options with section headers, and full keyboard navigation.
   * @slot item - Use this slot to customize the content of each item.
   * @event open {CustomEvent<void>} - Emits when the listbox should open.
   * @event close {CustomEvent<void>} - Emits when the listbox should close.
   */
  import { Check20, CheckCircleFilled24, Less20, Search24 } from '@mozaic-ds/icons-svelte';
  import Button from '../button/Button.svelte';
  import Textinput from '../textinput/Textinput.svelte';
  import { SvelteMap } from 'svelte/reactivity';
  import type { Snippet } from 'svelte';

  export type ListboxOption = {
    label: string;
    content?: string;
    value?: string | number;
    disabled?: boolean;
    type?: 'option' | 'section';
  };

  interface Props {
    /**
     * The current selected value(s) of the listbox.
     */
    value: string | number | null | (string | number)[];
    /**
     * Unique identifier for the listbox.
     */
    id: string;
    /**
     * Whether the listbox is open.
     */
    open?: boolean;
    /**
     * Enable multiple value.
     */
    multiple?: boolean;
    /**
     * Make the listbox read-only.
     */
    readonly?: boolean;
    /**
     * Show a search input above the options.
     */
    search?: boolean;
    /**
     * Show select all / clear buttons when multiple.
     */
    actions?: boolean;
    /**
     * Enable checkable section headers.
     */
    checkablesections?: boolean;
    /**
     * Placeholder text for the search input.
     */
    searchplaceholder?: string;
    /**
     * Label for the "Select all" button.
     */
    selectlabel?: string;
    /**
     * Label for the "Clear value" button.
     */
    clearlabel?: string;
    /**
     * Array of options and sections to display in the listbox.
     */
    options: Array<ListboxOption>;
    /**
     * Callback to trigge when the listbox should open.
     */
    onopen?: () => void;
    /**
     * Callback to trigge when the listbox should close.
     */
    onclose?: () => void;
    activeindex: number;
    element?: HTMLElement | null;
    item?: Snippet<[{ option: ListboxOption }]>;
  }

  let {
    value = $bindable(),
    id,
    open = $bindable(),
    multiple,
    readonly,
    search,
    actions,
    checkablesections,
    searchplaceholder = 'Find an option...',
    selectlabel = 'Select all',
    clearlabel = 'Clear',
    options,
    onopen,
    onclose,
    activeindex = $bindable(),
    element = $bindable(),
    item,
  }: Props = $props();

  let textInput: HTMLElement | null = $state(null);

  let searchText = $state('');
  let filteredResults = $state<ListboxOption[]>(options);

  let searchDebounce: ReturnType<typeof setTimeout> | undefined;

  const activeDescendantId = $derived.by(() => {
    return activeindex >= 0 ? `option-${id}-${activeindex}` : undefined;
  });

  function updateFilteredResults() {
    clearTimeout(searchDebounce);

    searchDebounce = setTimeout(() => {
      const search = searchText.toLowerCase().trim();

      if (!search) {
        filteredResults = options;
        return;
      }

      filteredResults = options.filter((option) => option.label.toLowerCase().includes(search));

      activeindex = filteredResults.length ? 0 : -1;
    }, 200);
  }

  const sectionMap = $derived.by(() => {
    const map = new SvelteMap<string, ListboxOption[]>();
    let currentSection: ListboxOption | null = null;

    options.forEach((option) => {
      if (option.type === 'section') {
        currentSection = option;
        map.set(currentSection?.value?.toString() || currentSection.label, []);
      } else if (currentSection) {
        map.get(currentSection?.value?.toString() || currentSection.label)!.push(option);
      }
    });

    return map;
  });

  function toggleSection(item: ListboxOption) {
    if (!checkablesections || !multiple) return;

    const sectionItems = sectionMap.get(item.value?.toString() || item.label) || [];
    const selectedItems = value as (string | number)[];

    if (isSectionSelected(item)) {
      value = selectedItems.filter((opt) => !sectionItems.find((item) => item.value === opt));
    } else {
      value = [
        ...selectedItems,
        ...sectionItems
          .filter((opt) => !selectedItems.includes(opt.value!))
          .map((item) => item.value!),
      ];
    }
  }

  export function toggleValue(item?: ListboxOption) {
    if (!item || !isSelectable(item)) return;

    if (Array.isArray(value)) {
      if (isOptionSelected(item)) {
        value = (value as (string | number)[]).filter((el) => el !== item.value);
      } else {
        value = [...value, item.value!];
      }
    } else {
      value = isOptionSelected(item) ? null : item.value!;
    }
  }

  function selectAll() {
    value = [
      ...options
        .filter((option) => !!option.value && !option.disabled && option.type !== 'section')
        .map((item) => item.value!),
    ];
  }

  export function clearSelection() {
    if (Array.isArray(value)) {
      value = [];
    } else {
      value = null;
    }
  }

  function isSelectable(item: ListboxOption) {
    return (
      (item.type !== 'section' && !item.disabled) ||
      (item.type === 'section' && checkablesections && multiple)
    );
  }

  function isSectionSelected(item: ListboxOption) {
    if (!checkablesections || !multiple) return false;

    const sectionItems = sectionMap.get(item.value?.toString() || item.label) || [];
    const selectedItems = value as (string | number)[];

    return sectionItems.every((opt) => selectedItems.includes(opt.value!));
  }

  function isOptionSelected(item: ListboxOption) {
    if (!item.value) return false;

    if (Array.isArray(value)) {
      return (value as (string | number)[])?.includes(item.value!);
    } else {
      return item.value === value;
    }
  }

  function isIndeterminate(item: ListboxOption) {
    const section = sectionMap.get(item.value?.toString() || item.label);
    return (
      section?.some((option) => isOptionSelected(option)) &&
      !section?.every((option) => isOptionSelected(option))
    );
  }

  function moveActive(delta: number) {
    if (!open || filteredResults.length === 0) return;

    let nextIndex = activeindex + delta;

    if (nextIndex < 0) nextIndex = filteredResults.length - 1;
    if (nextIndex >= filteredResults.length) nextIndex = 0;

    while (!isSelectable(filteredResults[nextIndex])) {
      nextIndex += delta > 0 ? 1 : -1;
      if (nextIndex < 0) nextIndex = filteredResults.length - 1;
      if (nextIndex >= filteredResults.length) nextIndex = 0;
    }

    activeindex = nextIndex;
  }

  function selectActive() {
    const item = filteredResults[activeindex];
    if (!item || !isSelectable(item)) return;

    if (item.type === 'section') {
      toggleSection(item);
    } else {
      toggleValue(item);
    }
  }

  function handleOpen() {
    onopen?.();
    element?.dispatchEvent(new CustomEvent('open', { bubbles: true, composed: true }));
  }

  function handleClose() {
    onclose?.();
    element?.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
  }

  export function handleKeydown(event: KeyboardEvent) {
    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        if (!open) {
          handleOpen();
          activeindex = 0;
        } else {
          moveActive(1);
        }
        break;
      case 'ArrowUp':
        event.preventDefault();
        if (!open) {
          handleOpen();
          activeindex = filteredResults.length - 1;
        } else {
          moveActive(-1);
        }
        break;
      case 'Enter':
        event.preventDefault();
        if (!open) {
          handleOpen();
          activeindex = 0;
        }
        selectActive();
        break;
      case 'Escape':
        event.preventDefault();
        handleClose();
        break;
    }
  }

  $effect(() => {
    if (open) {
      setTimeout(() => {
        textInput?.focus();
      }, 50);
    }
  });
</script>

<div bind:this={element} class="mc-option-listbox mc-listbox__content mc-combobox__listbox">
  {#if search}
    <div class="mc-option-listbox__search">
      <Textinput
        bind:element={textInput}
        bind:value={searchText}
        role="combobox"
        id={`search-${id}`}
        size="s"
        placeholder={searchplaceholder}
        autocomplete="off"
        aria-expanded={open}
        aria-controls={`listbox-${id}`}
        aria-autocomplete="list"
        aria-activedescendant={activeDescendantId}
        oninput={updateFilteredResults}
        onkeydown={handleKeydown}
      >
        <Search24 slot="icon" />
      </Textinput>
    </div>

    <hr class="mc-option-listbox__separator" />
  {/if}

  {#if multiple && actions}
    <div class="mc-option-listbox__actions">
      <Button appearance="accent" ghost size="s" onclick={selectAll}>
        {selectlabel}
      </Button>
      <Button appearance="standard" ghost size="s" onclick={clearSelection}>
        {clearlabel}
      </Button>
    </div>
    <hr class="mc-option-listbox__separator" />
  {/if}

  <ul
    class="mc-option-listbox__list"
    role="listbox"
    id={`listbox-${id}`}
    tabindex={-1}
    aria-label="Suggestions"
    aria-multiselectable={multiple}
  >
    {#each filteredResults as option, index (index)}
      <li
        id={`option-${id}-${index}`}
        class={{
          'mc-option-listbox__item': true,
          'mc-option-listbox__item--section': option.type === 'section',
          'mc-option-listbox__item--readonly': readonly,
          'mc-option-listbox__item--disabled': option.disabled,
          'mc-option-listbox__item--selectable': isSelectable(option),
          'mc-option-listbox__item--active': activeindex === index,
          'mc-option-listbox__item--selected':
            option.type === 'section'
              ? isSectionSelected(option) || isIndeterminate(option)
              : isOptionSelected(option),
        }}
        {...option.type === 'section' && !checkablesections
          ? { role: 'presentation' }
          : {
              role: 'option',
              ['aria-disabled']: option.disabled,
              ['aria-selected']: isOptionSelected(option),
              onclick: () =>
                option.type === 'section' ? toggleSection(option) : toggleValue(option),
            }}
      >
        <div class="mc-option-listbox__label">
          {#if item}
            {@render item({ option })}
          {:else}
            <div class="mc-option-listbox__content">
              <span
                class={option.type === 'section'
                  ? 'mc-option-listbox__section-title'
                  : 'mc-option-listbox__text'}
              >
                {option.label}
              </span>

              {#if option.content}
                <span class="mc-option-listbox__additional">
                  {option.content}
                </span>
              {/if}
            </div>
          {/if}

          <div class="mc-option-listbox__spacer"></div>

          {#if isSelectable(option)}
            {#if option.type === 'section'}
              <span class="mc-option-listbox__checkbox">
                {#if isSectionSelected(option) || isIndeterminate(option)}
                  {@const Icon = isIndeterminate(option) ? Less20 : Check20}
                  <Icon />
                {/if}
              </span>
            {:else if multiple}
              <span class="mc-option-listbox__checkbox">
                {#if isOptionSelected(option)}
                  <Check20 />
                {/if}
              </span>
            {:else}
              <span class="mc-option-listbox__selection-icon">
                <CheckCircleFilled24 />
              </span>
            {/if}
          {/if}
        </div>
      </li>
    {/each}
  </ul>
</div>

<style>/**
 * Do not edit directly, this file was auto-generated.
 */
.mc-option-listbox__list {
  overflow: auto;
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
  margin: 0;
  padding: 0.5rem;
  max-height: 18.75rem;
}
.mc-option-listbox__item {
  display: block;
  border-radius: var(--border-radius-s, 0.25rem);
}
.mc-option-listbox__item--disabled {
  color: var(--option-listbox-color-text-disabled, #b3b3b3);
}
.mc-option-listbox__item--disabled .mc-option-listbox__label {
  pointer-events: none;
  color: var(--option-listbox-color-text-disabled, #b3b3b3);
}
.mc-option-listbox__item--disabled .mc-option-listbox__additional {
  color: var(--option-listbox-color-text-disabled, #b3b3b3);
}
.mc-option-listbox__item--disabled .mc-option-listbox__checkbox {
  border: none;
  background-color: var(--forms-color-background-disabled, #d9d9d9);
}
.mc-option-listbox__item--readonly .mc-option-listbox__label {
  pointer-events: none;
}
.mc-option-listbox__item--selected:not(.mc-option-listbox__item--section) {
  background-color: var(--option-listbox-color-background-checked, #ebf5de);
}
.mc-option-listbox__item--selected .mc-option-listbox__selection-icon {
  opacity: 1;
  color: var(--option-listbox-color-selection-indicator-default, #117f03);
  fill: currentcolor;
}
.mc-option-listbox__item--selected .mc-option-listbox__checkbox {
  border: none;
  background-color: var(--forms-color-background-checked, #117f03);
  color: var(--forms-color-icon-inverse, #ffffff);
  fill: currentcolor;
}
.mc-option-listbox__item--selected .mc-option-listbox__checkbox svg {
  display: block;
}
.mc-option-listbox__item--readonly.mc-option-listbox__item--selected {
  background-color: var(--option-listbox-color-background-checked-read-only, #eff1f6);
}
.mc-option-listbox__item--readonly.mc-option-listbox__item--selected .mc-option-listbox__selection-icon {
  color: var(--option-listbox-color-selection-indicator-read-only, #000000);
}
.mc-option-listbox__item--readonly.mc-option-listbox__item--selected .mc-option-listbox__checkbox {
  border: 2px solid var(--forms-color-border-read-only, #cccccc);
  background-color: var(--forms-color-background-default, #ffffff);
  color: var(--option-listbox-color-selection-indicator-read-only, #000000);
}
.mc-option-listbox__item--selectable {
  cursor: pointer;
}
.mc-option-listbox__item--selectable:not(.mc-option-listbox__item--disabled):hover, .mc-option-listbox__item--selectable:not(.mc-option-listbox__item--disabled).mc-option-listbox__item--active {
  background-color: var(--option-listbox-color-background-hover, rgba(0, 0, 0, 0.05));
}
.mc-option-listbox__content {
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
}
.mc-option-listbox__additional {
  font-weight: var(--font-weight-regular, 400);
  font-size: var(--font-size-50, 0.75rem);
  color: var(--option-listbox-color-text-information, #666666);
}
.mc-option-listbox__label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  height: 3rem;
  padding: 0.5rem;
  border-radius: var(--border-radius-s, 0.25rem);
  line-height: var(--line-height-s, 1.3);
  box-sizing: border-box;
}
.mc-option-listbox__checkbox {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 1.25rem;
  width: 1.25rem;
  border: 0.125rem solid var(--forms-color-border-default, #666666);
  border-radius: 0.25rem;
  box-sizing: border-box;
}
.mc-option-listbox__checkbox svg {
  display: none;
}
.mc-option-listbox__spacer {
  flex-grow: 1;
}
.mc-option-listbox__selection-icon {
  opacity: 0;
}
.mc-option-listbox__section-title {
  font-size: var(--font-size-100, 0.875rem);
  font-weight: var(--font-weight-semi-bold, 600);
  margin: 0;
  color: var(--option-listbox-color-text-section-title, #666666);
}
.mc-option-listbox__search {
  margin: 0.5rem;
  width: unset;
}
.mc-option-listbox__separator {
  display: block;
  height: 1px;
  border: 0;
  border-top: 1px solid var(--color-border-primary, #cccccc);
  margin: 0;
  padding: 0;
}
.mc-option-listbox__actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.5rem 1rem;
}</style>
