jmou/yakatak
git clone https://github.com/jmou/yakatak.git
git clone git@github.com:jmou/yakatak.git
Loading…
(top)/ui/src/components/ChooserDialog.vue
language: Vue
3.43 KB / 138 lines / 119 loc
History
View raw
<script setup lang="ts">
import { assert } from "@/lib/common";
import { nextTick, onMounted, reactive, useTemplateRef } from "vue";

// Doesn't provide any type safety but clarifies intention.
type OptionValue = unknown;

const state = reactive({
  title: "",
  labels: [] as string[],
  values: null as OptionValue[] | null,
  selectedIndex: -1,
});

const elem = useTemplateRef("elem");

type ResolveFunction = (value: OptionValue | null) => void;
let resolveAsk: ResolveFunction | null = null;

onMounted(() => {
  elem.value!.addEventListener("cancel", () => {
    state.selectedIndex = -1;
  });

  elem.value!.addEventListener("close", () => {
    if (resolveAsk) {
      if (state.selectedIndex < 0) {
        resolveAsk(null);
      } else if (state.values) {
        resolveAsk(state.values[state.selectedIndex]);
      } else {
        resolveAsk(state.selectedIndex);
      }
      resolveAsk = null;
    }
  });
});

function onClickLabel(event: PointerEvent) {
  // Are we actually a keyboard "click"?
  // See https://stackoverflow.com/a/35034284/13773246
  if (event.clientX === 0 && event.clientY === 0) return;
  elem.value!.close();
}

/**
 * Present to the user a dialog for choosing among the provided labels.
 * @param title Dialog title
 * @param labels Display texts for the user to choose among
 * @returns The index of the user's choice
 */
function ask(title: string, labels: string[]): Promise<number | null>;
/**
 * Present to the user a dialog for choosing among the provided labels.
 * @param title Dialog title
 * @param labels Display texts for the user to choose among
 * @param values Values corresponding to `labels`
 * @returns The value at the index of the user's choice
 */
function ask<T>(title: string, labels: string[], values: T[]): Promise<T | null>;
function ask<T = number>(title: string, labels: string[], values?: T[]): Promise<T | null> {
  assert(values == null || labels.length === values.length);
  state.title = title;
  state.labels = labels;
  state.values = values ?? null;
  state.selectedIndex = 0;

  nextTick(() => {
    elem.value!.showModal();
    elem.value!.querySelector<HTMLElement>("input:checked")?.focus();
  });

  return new Promise((resolve) => (resolveAsk = resolve as ResolveFunction));
}

defineExpose({ ask });
</script>

<template>
  <dialog ref="elem" closedby="any">
    <header>{{ state.title }}</header>
    <form method="dialog">
      <label v-for="(label, i) in state.labels" :key="i" @click="onClickLabel">
        <input type="radio" :value="i" v-model="state.selectedIndex" />
        {{ label }}
      </label>
      <!-- Only exists to allow implicit form submission with Enter key -->
      <input type="submit" tabindex="-1" style="display: none" />
    </form>
  </dialog>
</template>

<style scoped>
dialog {
  margin-top: 2em;
  padding: 0;
  background: #ddd;
  border-radius: 8px;
  box-shadow: 0 0 6px #0004;
  border: 1px solid #333;

  > header {
    margin: 0 1em;
    padding: 0.7em 0.5em 0.3em;
    border-bottom: 1px solid #ccc;
    font-weight: bold;
  }

  > form {
    margin: 0.8em 1em 1em;
  }

  &::backdrop {
    background-color: #ddd5;
  }
}

label {
  display: block;
  padding: 0.5em;
  margin: 0.5em 0;
  background: #eee;
  border: 1px solid #888;
  border-radius: 8px;
  cursor: pointer;

  &:has(input:checked) {
    border-color: #38f;
    outline: 3px solid #acf;
  }
}

/* Hide but still support keyboard focusing. */
input[type="radio"] {
  position: absolute;
  opacity: 0;
}
</style>