1
0
Sv443 6 сар өмнө
parent
commit
59786b0a6a

+ 2 - 0
src/components/index.ts

@@ -4,6 +4,8 @@ export * from "./ExImDialog.js";
 export * from "./hotkeyInput.js";
 export * from "./longButton.js";
 export * from "./MarkdownDialog.js";
+export * from "./multiSelect.js";
+export * from "./popper.js";
 export * from "./ripple.js";
 export * from "./toast.js";
 export * from "./toggleInput.js";

+ 3 - 0
src/components/multiSelect.css

@@ -0,0 +1,3 @@
+.bytm-multi-select-list.hidden {
+  display: none;
+}

+ 124 - 0
src/components/multiSelect.old.ts

@@ -0,0 +1,124 @@
+import type { Stringifiable } from "@sv443-network/userutils";
+import type { StringGen } from "../types.js";
+import { consumeStringGen } from "../utils/misc.js";
+
+export type MultiSelectProps<TValues extends Stringifiable = Stringifiable> = {
+  /** ID that needs to be unique between all multi select elements */
+  id: string;
+  /** Array of options to choose from */
+  options: Array<(
+    & {
+      value: TValues;
+    }
+    & (
+      | {
+        /** If this is given, this will be the value of the `title` and `aria-value-text` */
+        labelStr: StringGen;
+      }
+      | {
+        /** If this is set, the `htmlFor` needs to be set to `checkboxId` and a `aria-value-text` attribute should be set on the returned element that describes the current checked state */
+        labelElem: HTMLLabelElement | ((checkboxId: string) => HTMLLabelElement) | ((checkboxId: string) => Promise<HTMLLabelElement>);
+      }
+    )
+  )>;
+  /** Called when the selection changes with an array of `value` properties of the selected options - empty array when nothing is selected */
+  onChange: (values: TValues[]) => void;
+  /** Title / tooltip */
+  title?: StringGen;
+};
+
+/**
+ * Creates a multi-select element with the specified options.  
+ * The element starts as a `button`, when clicked it expands a list of checkbox elements that uses the popper library to anchor to the button.  
+ * When the mouse leaves the list, it collapses back into the button.  
+ * The `onChange` callback will be called with an array of the `value` properties of the selected options.
+ */
+export async function createMultiSelect<TValues extends Stringifiable = Stringifiable>({
+  id,
+  options,
+  onChange,
+  title,
+}: MultiSelectProps<TValues>) {
+  const containerEl = document.createElement("div");
+  containerEl.classList.add("bytm-multi-select-container");
+
+  const selectBtnEl = document.createElement("button");
+  const listEl = document.createElement("div");
+
+  const hide = () => {
+    if(listEl.classList.contains("hidden"))
+      return;
+    listEl.classList.add("hidden");
+    selectBtnEl.textContent = "Expand";
+  };
+
+  listEl.classList.add("bytm-multi-select-list");
+  listEl.addEventListener("mouseleave", () => {
+    hide();
+  });
+  hide();
+
+  const show = () => {
+    if(!listEl.classList.contains("hidden"))
+      return;
+    listEl.classList.remove("hidden");
+    selectBtnEl.textContent = "Collapse";
+  };
+
+  selectBtnEl.classList.add("bytm-multi-select-btn");
+  if(title)
+    selectBtnEl.title = selectBtnEl.ariaLabel = await consumeStringGen(title);
+  selectBtnEl.addEventListener("click", () => {
+    if(listEl.classList.contains("hidden"))
+      show();
+    else
+      hide();
+  });
+  selectBtnEl.addEventListener("mouseenter", () => {
+    show();
+  });
+  selectBtnEl.addEventListener("mouseleave", (e) => {
+    const relTarget = e.relatedTarget as HTMLElement;
+    if(!["bytm-multi-select-option", "bytm-multi-select-list", "bytm-multi-select-option-label"].some((cl) => relTarget.classList.contains(cl)))
+      hide();
+  });
+
+  containerEl.appendChild(selectBtnEl);
+  containerEl.appendChild(listEl);
+
+  const getCheckedOptions = (): TValues[] => {
+    return Array.from(listEl.querySelectorAll<HTMLInputElement>(".bytm-multi-select-checkbox:checked"))
+      .map(checkbox => (checkbox.closest(".bytm-multi-select-option") as HTMLElement)?.dataset.value as TValues)
+      .filter(value => value !== undefined);
+  };
+
+  for(const opt of options) {
+    const optId = `bytm-multi-select-option-${id}-${opt.value}`;
+
+    const optEl = document.createElement("div");
+    optEl.classList.add("bytm-multi-select-option");
+    optEl.dataset.value = String(opt.value);
+
+    const checkboxEl = document.createElement("input");
+    checkboxEl.id = optId;
+    checkboxEl.type = "checkbox";
+    checkboxEl.classList.add("bytm-multi-select-checkbox");
+    checkboxEl.addEventListener("change", () => {
+      onChange(getCheckedOptions());
+    });
+    optEl.appendChild(checkboxEl);
+
+    if("labelElem" in opt)
+      optEl.appendChild(typeof opt.labelElem === "function" ? await opt.labelElem(optId) : opt.labelElem);
+    else if("labelStr" in opt) {
+      const lblEl = document.createElement("label");
+      lblEl.classList.add("bytm-multi-select-option-label");
+      lblEl.htmlFor = optId;
+      lblEl.textContent = optEl.ariaValueText = await consumeStringGen(opt.labelStr);
+      optEl.appendChild(lblEl);
+    }
+    listEl.appendChild(optEl);
+  }
+
+  return containerEl;
+}

+ 123 - 0
src/components/multiSelect.ts

@@ -0,0 +1,123 @@
+import type { Stringifiable } from "@sv443-network/userutils";
+import type { StringGen } from "../types.js";
+import { consumeStringGen } from "../utils/misc.js";
+import "./multiSelect.css";
+
+export type MultiSelectProps<TValues extends Stringifiable = Stringifiable> = {
+  /** ID that needs to be unique between all multi select elements */
+  id: string;
+  /** Array of options to choose from */
+  options: Array<(
+    & {
+      value: TValues;
+    }
+    & (
+      | {
+        /** If this is given, this will be the value of the `title` and `aria-value-text` */
+        labelStr: StringGen;
+      }
+      | {
+        /** If this is set, the `htmlFor` needs to be set to `checkboxId` and a `aria-value-text` attribute should be set on the returned element that describes the current checked state */
+        labelElem: HTMLLabelElement | ((checkboxId: string) => HTMLLabelElement) | ((checkboxId: string) => Promise<HTMLLabelElement>);
+      }
+    )
+  )>;
+  /** Called when the selection changes with an array of `value` properties of the selected options - empty array when nothing is selected */
+  onChange: (values: TValues[]) => void;
+  /** Title / tooltip */
+  title?: StringGen;
+};
+
+/**
+ * Creates a multi-select element with the specified options.  
+ * The element starts as a `button`, when clicked it expands a list of checkbox elements that uses the popper library to anchor to the button.  
+ * When the mouse leaves the list, it collapses back into the button.  
+ * The `onChange` callback will be called with an array of the `value` properties of the selected options.
+ */
+export async function createMultiSelect<TValues extends Stringifiable = Stringifiable>({
+  id,
+  options,
+  onChange,
+  title,
+}: MultiSelectProps<TValues>) {
+  const containerEl = document.createElement("div");
+  containerEl.classList.add("bytm-multi-select-container");
+
+  const selectBtnEl = document.createElement("button");
+  const listEl = document.createElement("div");
+
+  const hide = () => {
+    if(listEl.classList.contains("hidden"))
+      return;
+    listEl.classList.add("hidden");
+    selectBtnEl.textContent = "Expand";
+  };
+
+  listEl.classList.add("bytm-multi-select-list");
+  listEl.addEventListener("mouseleave", () => {
+    hide();
+  });
+  hide();
+
+  const show = () => {
+    if(!listEl.classList.contains("hidden"))
+      return;
+    listEl.classList.remove("hidden");
+    selectBtnEl.textContent = "Collapse";
+  };
+
+  selectBtnEl.classList.add("bytm-multi-select-btn");
+  if(title)
+    selectBtnEl.title = selectBtnEl.ariaLabel = await consumeStringGen(title);
+  selectBtnEl.addEventListener("click", () => {
+    if(listEl.classList.contains("hidden"))
+      show();
+    else
+      hide();
+  });
+  selectBtnEl.addEventListener("mouseenter", () => {
+    show();
+  });
+  selectBtnEl.addEventListener("mouseleave", (e) => {
+    const relTarget = e.relatedTarget as HTMLElement;
+    if(!["bytm-multi-select-option", "bytm-multi-select-list", "bytm-multi-select-option-label"].some((cl) => relTarget.classList.contains(cl)))
+      hide();
+  });
+
+  containerEl.appendChild(selectBtnEl);
+  containerEl.appendChild(listEl);
+
+  for(const opt of options) {
+    const optId = `bytm-multi-select-option-${id}-${opt.value}`;
+
+    const optEl = document.createElement("div");
+    optEl.classList.add("bytm-multi-select-option");
+    optEl.dataset.value = String(opt.value);
+
+    const checkboxEl = document.createElement("input");
+    checkboxEl.id = optId;
+    checkboxEl.type = "checkbox";
+    checkboxEl.classList.add("bytm-multi-select-checkbox");
+    checkboxEl.addEventListener("change", () => {
+      onChange(
+        Array.from(listEl.querySelectorAll<HTMLInputElement>(".bytm-multi-select-checkbox:checked"))
+          .map(checkbox => (checkbox.closest(".bytm-multi-select-option") as HTMLElement)?.dataset.value as TValues)
+          .filter(value => value !== undefined)
+      );
+    });
+    optEl.appendChild(checkboxEl);
+
+    if("labelElem" in opt)
+      optEl.appendChild(typeof opt.labelElem === "function" ? await opt.labelElem(optId) : opt.labelElem);
+    else if("labelStr" in opt) {
+      const lblEl = document.createElement("label");
+      lblEl.classList.add("bytm-multi-select-option-label");
+      lblEl.htmlFor = optId;
+      lblEl.textContent = optEl.ariaValueText = await consumeStringGen(opt.labelStr);
+      optEl.appendChild(lblEl);
+    }
+    listEl.appendChild(optEl);
+  }
+
+  return { element: containerEl, show, hide };
+}

+ 7 - 0
src/components/popper.css

@@ -0,0 +1,7 @@
+.bytm-popper-content {
+  position: fixed;
+}
+
+.bytm-popper-content.hidden {
+  display: none;
+}

+ 90 - 0
src/components/popper.ts

@@ -0,0 +1,90 @@
+import { randomId } from "@sv443-network/userutils";
+import "./popper.css";
+
+export type PopperProps = {
+  id?: string;
+  referenceElement: HTMLElement;
+  popperContent: HTMLElement;
+  placement?: "top" | "bottom" | "left" | "right";
+};
+
+/**
+ * Creates a popper-type tooltip anchored to the `referenceElement` that will be displayed when the `referenceElement` is hovered over.  
+ * The `popperElement` will be displayed in the specified `placement` relative to the `referenceElement`.
+ */
+export function createPopper({
+  id = `bytm-popper-${randomId(5, 36)}`,
+  referenceElement,
+  popperContent,
+  placement = "bottom",
+}: PopperProps) {
+  referenceElement.classList.add("bytm-popper-reference", placement);
+  referenceElement.setAttribute("aria-describedby", id);
+  referenceElement.dataset.popperId = id;
+  popperContent.classList.add("bytm-popper-content", placement, "hidden");
+  popperContent.id = id;
+
+  const updatePos = () => {
+    const refRect = referenceElement.getBoundingClientRect();
+    const popperRect = popperContent.getBoundingClientRect();
+
+    let top: number;
+    let left: number;
+
+    console.log(placement, refRect, popperRect);
+
+    switch(placement) {
+    case "top":
+      top = refRect.top - popperRect.height;
+      left = refRect.left + (refRect.width - popperRect.width) / 2;
+      break;
+    case "bottom":
+      top = refRect.bottom;
+      left = refRect.left + (refRect.width - popperRect.width) / 2;
+      break;
+    case "left":
+      top = refRect.top + (refRect.height - popperRect.height) / 2;
+      left = refRect.left - popperRect.width;
+      break;
+    case "right":
+      top = refRect.top + (refRect.height - popperRect.height) / 2;
+      left = refRect.right;
+      break;
+    }
+
+    popperContent.style.top = `${top}px`;
+    popperContent.style.left = `${left}px`;
+  };
+
+  const showPopper = () => {
+    if(!popperContent.classList.contains("hidden"))
+      return;
+    popperContent.classList.remove("hidden");
+    popperContent.setAttribute("aria-hidden", "false");
+    updatePos();
+  };
+
+  const hidePopper = () => {
+    if(popperContent.classList.contains("hidden"))
+      return;
+    popperContent.classList.add("hidden");
+    popperContent.setAttribute("aria-hidden", "true");
+  };
+
+  referenceElement.addEventListener("mouseenter", () => {
+    showPopper();
+  });
+  referenceElement.addEventListener("mouseleave", (e) => {
+    const relTarget = e.relatedTarget as HTMLElement;
+    if(!relTarget || !["bytm-popper-reference", "bytm-popper-content"].some((cl) => relTarget.classList.contains(cl)))
+      hidePopper();
+  });
+  popperContent.addEventListener("mouseenter", () => {
+    showPopper();
+  });
+  popperContent.addEventListener("mouseleave", (e) => {
+    const relTarget = e.relatedTarget as HTMLElement;
+    if(!relTarget || !["bytm-popper-reference", "bytm-popper-content"].some((cl) => relTarget.classList.contains(cl)))
+      hidePopper();
+  });
+};

+ 37 - 1
src/index.ts

@@ -36,7 +36,8 @@ import {
   addConfigMenuOptionYT, addConfigMenuOptionYTM,
 } from "./features/index.js";
 import { storeSerializer } from "./storeSerializer.js";
-import { MarkdownDialog } from "./components/index.js";
+import { BytmDialog, MarkdownDialog } from "./components/index.js";
+import { createPopper } from "./components/popper.js";
 
 //#region cns. watermark
 
@@ -535,3 +536,38 @@ function registerDevCommands() {
 }
 
 preInit();
+
+document.addEventListener("DOMContentLoaded", () => {
+  const dlg = new BytmDialog({
+    id: "test-angkga",
+    height: 400,
+    width: 500,
+    renderHeader() {
+      const header = document.createElement("h1");
+      header.textContent = "Test Dialog";
+      return header;
+    },
+    async renderBody() {
+      const body = document.createElement("div");
+      const popperTxt = document.createElement("p");
+      popperTxt.textContent = "Popper test";
+
+      const popperCont = document.createElement("div");
+      popperCont.textContent = "Hello my friend I am the text";
+      popperCont.style.backgroundColor = "#010233";
+      popperCont.style.color = "#fff";
+      popperCont.style.padding = "10px";
+
+      createPopper({
+        referenceElement: popperTxt,
+        popperContent: popperCont,
+        placement: "top",
+      });
+
+      body.appendChild(popperTxt);
+      body.appendChild(popperCont);
+      return body;
+    },
+  });
+  dlg.open();
+});

+ 177 - 0
src/stories/MultiSelect.stories.ts

@@ -0,0 +1,177 @@
+import type { StoryObj, Meta } from "@storybook/html";
+import type { Stringifiable } from "@sv443-network/userutils";
+import { fn } from "@storybook/test";
+import "../features/layout.css";
+import "./multiSelect.css";
+
+//#region meta
+
+const meta = {
+  title: "MultiSelect",
+  render,
+  argTypes: {
+    onChange: { action: "onClick" },
+  },
+  // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
+  args: { onChange: fn() },
+} satisfies Meta<GenericBtnStoryArgs>;
+
+export default meta;
+
+type GenericBtnStoryArgs = {
+  onChange: (values: Stringifiable[]) => void;
+};
+
+type Story = StoryObj<GenericBtnStoryArgs>;
+
+//#region render
+
+function render(args: GenericBtnStoryArgs) {
+  const wrapperEl = document.createElement("div");
+
+  createMultiSelect({
+    ...args,
+    id: "multi-select-1",
+    options: [
+      { value: "val1", labelStr: "Option 1" },
+      { value: "val2", labelStr: async () => "Option 2" },
+      {
+        value: "val3",
+        labelElem: (chkId: string) => {
+          const el = document.createElement("label");
+          el.textContent = "Option 3";
+          el.htmlFor = chkId;
+          return el;
+        },
+      },
+    ],
+  }).then(multiSelect => wrapperEl.appendChild(multiSelect));
+
+  return wrapperEl;
+}
+
+type StringGen = Stringifiable | (() => Stringifiable | Promise<Stringifiable>);
+
+type MultiSelectProps<TValues extends Stringifiable = Stringifiable> = {
+  /** ID that is unique between all multi select elements */
+  id: string;
+  /** Array of options to choose from */
+  options: Array<(
+    & {
+      value: TValues;
+    }
+    & (
+      | {
+        /** If this is given, this will be the value of the `title` and `aria-value-text` */
+        labelStr: StringGen;
+      }
+      | {
+        /** If this is set, the `htmlFor` needs to be set to `checkboxId` and a `aria-value-text` attribute should be set on the returned element that describes the current checked state */
+        labelElem: HTMLLabelElement | ((checkboxId: string) => HTMLLabelElement) | ((checkboxId: string) => Promise<HTMLLabelElement>);
+      }
+    )
+  )>;
+  /** Called when the selection changes with an array of `value` properties of the selected options - empty array when nothing is selected */
+  onChange: (values: TValues[]) => void;
+  /** Title / tooltip */
+  title?: StringGen;
+};
+
+async function createMultiSelect<TValues extends Stringifiable = Stringifiable>({
+  id,
+  options,
+  onChange,
+  title,
+}: MultiSelectProps<TValues>) {
+  const containerEl = document.createElement("div");
+  containerEl.classList.add("bytm-multi-select-container");
+
+  const selectBtnEl = document.createElement("button");
+
+  const listEl = document.createElement("div");
+
+  const hide = () => {
+    listEl.classList.add("hidden");
+    selectBtnEl.textContent = "Expand";
+  };
+
+  listEl.classList.add("bytm-multi-select-list");
+  listEl.addEventListener("mouseleave", () => {
+    hide();
+  });
+  hide();
+
+  const show = () => {
+    listEl.classList.remove("hidden");
+    selectBtnEl.textContent = "Collapse";
+  };
+
+  selectBtnEl.classList.add("bytm-multi-select-btn");
+  if(title)
+    selectBtnEl.title = selectBtnEl.ariaLabel = await consumeStringGen(title);
+  selectBtnEl.textContent = "Expand";
+  selectBtnEl.addEventListener("click", () => {
+    if(listEl.classList.contains("hidden"))
+      show();
+    else
+      hide();
+  });
+  selectBtnEl.addEventListener("mouseenter", () => {
+    show();
+  });
+  selectBtnEl.addEventListener("mouseleave", (e) => {
+    const relTarget = e.relatedTarget as HTMLElement;
+    if(!["bytm-multi-select-option", "bytm-multi-select-list", "bytm-multi-select-option-label"].some((cl) => relTarget.classList.contains(cl)))
+      hide();
+  });
+
+  containerEl.appendChild(selectBtnEl);
+  containerEl.appendChild(listEl);
+
+  const getCheckedOptions = (): TValues[] => {
+    return Array.from(listEl.querySelectorAll<HTMLInputElement>(".bytm-multi-select-checkbox:checked"))
+      .map(checkbox => (checkbox.closest(".bytm-multi-select-option") as HTMLElement)?.dataset.value as TValues)
+      .filter(value => value !== undefined);
+  };
+
+  for(const opt of options) {
+    const optId = `bytm-multi-select-option-${id}-${opt.value}`;
+
+    const optEl = document.createElement("div");
+    optEl.classList.add("bytm-multi-select-option");
+    optEl.dataset.value = String(opt.value);
+
+    const checkboxEl = document.createElement("input");
+    checkboxEl.id = optId;
+    checkboxEl.type = "checkbox";
+    checkboxEl.classList.add("bytm-multi-select-checkbox");
+    checkboxEl.addEventListener("change", () => {
+      onChange(getCheckedOptions());
+    });
+    optEl.appendChild(checkboxEl);
+
+    if("labelElem" in opt)
+      optEl.appendChild(typeof opt.labelElem === "function" ? await opt.labelElem(optId) : opt.labelElem);
+    else if("labelStr" in opt) {
+      const lblEl = document.createElement("label");
+      lblEl.classList.add("bytm-multi-select-option-label");
+      lblEl.htmlFor = optId;
+      lblEl.textContent = optEl.ariaValueText = await consumeStringGen(opt.labelStr);
+      optEl.appendChild(lblEl);
+    }
+    listEl.appendChild(optEl);
+  }
+
+  return containerEl;
+}
+
+async function consumeStringGen(strGen: StringGen): Promise<string> {
+  return String(typeof strGen === "function" ? await strGen() : strGen);
+}
+
+//#region stories
+
+export const MultiSelect: Story = {
+  args: {
+  },
+};

+ 3 - 0
src/stories/multiSelect.css

@@ -0,0 +1,3 @@
+.bytm-multi-select-list.hidden {
+  display: none;
+}