Procházet zdrojové kódy

fix: ripple works much better now

Sv443 před 10 měsíci
rodič
revize
13e6348fc2

+ 5 - 3
contributing.md

@@ -113,6 +113,8 @@ To edit an existing translation, please follow these steps:
   - `npm run preview` - Same as `npm run build-preview`, but also starts the dev server for a few seconds so the extension that's waiting for file changes can update the script
 - **`npm run lint`**  
   Builds the userscript with the TypeScript compiler and lints it with ESLint. Doesn't verify the functionality of the script, only checks for syntax and TypeScript errors!
+- **`npm run storybook`**  
+  Starts Storybook for developing and testing components. After launching, it will automatically open in your default browser.
 - **`npm run gen-readme`**  
   Updates the README files by inserting different parts of generated sections into them.
 - **`npm run tr-progress`**  
@@ -1428,15 +1430,15 @@ The usage and example blocks on each are written in TypeScript but can be used i
 > #### createRipple()
 > Usage:  
 > ```ts
-> unsafeWindow.BYTM.createRipple(rippleElement?: HTMLElement): HTMLElement
+> unsafeWindow.BYTM.createRipple(rippleElement?: HTMLElement, props?: RippleProps): HTMLElement
 > ```
 >   
 > Creates a circular, expanding ripple effect on the specified element or creates a new one with the effect already applied if none is provided.  
 > Returns either the new element or the initially passed one.  
 > Custom CSS overrides can be applied to change the color, size and transparency.  
-> Just make sure not to make the animation slower than it is, since the script will automatically remove the ripple elements after the animation is done.  
 >   
-> **This component is still experimental and might not work in some cases!**
+> Properties:  
+> - `speed?: string` - The speed of the ripple effect. Can be "fast", "normal" or "slow" (defaults to "normal")
 > 
 > <details><summary><b>Example <i>(click to expand)</i></b></summary>
 > 

+ 22 - 6
src/components/ripple.css

@@ -1,10 +1,26 @@
-:root html body .bytm-ripple {
+/* use `html body` prefix for more specificity */
+
+html body .bytm-ripple {
   position: relative;
-  width: var(--bytm-ripple-width, 100%);
-  height: var(--bytm-ripple-height, 100%);
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
+  width: var(--bytm-ripple-width);
+  height: var(--bytm-ripple-height);
+  user-select: none;
+}
+
+html body .bytm-ripple,
+html body .bytm-ripple.normal {
+  --bytm-ripple-anim-duration: 0.35s;
+}
+
+html body .bytm-ripple.fast {
+  --bytm-ripple-anim-duration: 0.2s;
+}
+
+html body .bytm-ripple.slow {
+  --bytm-ripple-anim-duration: 0.5s;
 }
 
 .bytm-ripple-area {
@@ -13,7 +29,7 @@
   transform: translate(-50%, -50%);
   pointer-events: none;
   border-radius: 50%;
-  animation: bytm-scale-ripple 0.25s ease-in;
+  animation: bytm-scale-ripple var(--bytm-ripple-anim-duration) ease-out;
 }
 
 @keyframes bytm-scale-ripple {
@@ -23,8 +39,8 @@
     opacity: 0.5;
   }
   100% {
-    width: 400px;
-    height: 400px;
+    width: calc(var(--bytm-ripple-cont-width) * 2);
+    height: calc(var(--bytm-ripple-cont-width) * 2);
     opacity: 0;
   }
 }

+ 23 - 7
src/components/ripple.ts

@@ -1,29 +1,44 @@
 import "./ripple.css";
 
+type RippleProps = {
+  /** How fast should the animation be */
+  speed?: "normal" | "fast" | "slow";
+};
+
 /**
  * Creates an element with a ripple effect on click.
  * @param clickEl If passed, this element will be modified to have the ripple effect. Otherwise, a new element will be created.
  * @returns The passed element or the newly created element with the ripple effect.
  */
-export function createRipple<TElem extends HTMLElement>(rippleElement: TElem): TElem;
+export function createRipple<TElem extends HTMLElement>(rippleElement: TElem, properties?: RippleProps): TElem;
 /**
  * Creates an element with a ripple effect on click.
  * @param clickEl If passed, this element will be modified to have the ripple effect. Otherwise, a new element will be created.
  * @returns The passed element or the newly created element with the ripple effect.
  */
-export function createRipple(rippleElement: undefined): HTMLDivElement;
+export function createRipple(rippleElement?: undefined, properties?: RippleProps): HTMLDivElement;
 /**
  * Creates an element with a ripple effect on click.
  * @param clickEl If passed, this element will be modified to have the ripple effect. Otherwise, a new element will be created.
  * @returns The passed element or the newly created element with the ripple effect.
  */
-export function createRipple<TElem extends HTMLElement>(rippleElement?: TElem) {
+export function createRipple<TElem extends HTMLElement>(rippleElement?: TElem, properties?: RippleProps) {
+  const props = {
+    speed: "normal",
+    ...properties,
+  };
+
   const rippleEl = rippleElement ?? document.createElement("div");
-  rippleEl.classList.add("bytm-ripple");
+  rippleEl.classList.add("bytm-ripple", props.speed);
+
+  const updateRippleWidth = () => 
+    rippleEl.style.setProperty("--bytm-ripple-cont-width", rippleEl.clientWidth + "px");
 
   rippleEl.addEventListener("click", (e) => {
-    const x = (e as MouseEvent).clientX - (e.target as HTMLElement)?.offsetLeft ?? 0;
-    const y = (e as MouseEvent).clientY - (e.target as HTMLElement)?.offsetTop ?? 0;
+    updateRippleWidth();
+
+    const x = (e as MouseEvent).clientX - rippleEl.getBoundingClientRect().left;
+    const y = (e as MouseEvent).clientY - rippleEl.getBoundingClientRect().top;
 
     const rippleAreaEl = document.createElement("span");
     rippleAreaEl.classList.add("bytm-ripple-area");
@@ -35,8 +50,9 @@ export function createRipple<TElem extends HTMLElement>(rippleElement?: TElem) {
     else
       rippleEl.appendChild(rippleAreaEl);
 
-    setTimeout(() => rippleAreaEl.remove(), 250);
+    rippleAreaEl.addEventListener("animationend", () => rippleAreaEl.remove());
   });
 
+  updateRippleWidth();
   return rippleEl;
 }

+ 1 - 1
src/stories/Button.stories.ts → src/stories/Button.stories.ts.disabled

@@ -5,7 +5,7 @@ import { createButton, type ButtonProps } from "./Button.js";
 // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
 const meta = {
   title: "Example/Example Button",
-  tags: ["autodocs"],
+  // tags: ["autodocs"],
   render: (args) => {
     // You can either use a function to create DOM elements or use a plain html string!
     // return `<div>${label}</div>`;

+ 2 - 0
src/stories/README.md

@@ -0,0 +1,2 @@
+## Stories
+This folder contains stories for developing and testing components, see https://storybook.js.org/docs/get-started

+ 63 - 0
src/stories/Ripple.stories.ts

@@ -0,0 +1,63 @@
+import type { StoryObj, Meta } from "@storybook/html";
+import { fn } from "@storybook/test";
+import { createRipple } from "../components/ripple.js";
+import "../components/ripple.css";
+
+const meta = {
+  title: "Ripple Button",
+  render: (args) => {
+    const btn = document.createElement("div");
+    btn.tabIndex = 0;
+    btn.style.height = "24px";
+    btn.style.display = "inline-flex";
+    btn.style.justifyContent = "center";
+    btn.style.alignItems = "center";
+    btn.style.borderRadius = "24px";
+    btn.style.cursor = "pointer";
+
+    btn.innerHTML = args.label;
+    btn.style.backgroundColor = args.backgroundColor;
+    btn.style.color = args.color;
+    btn.style.fontSize = args.fontSize;
+    btn.style.padding = args.padding;
+
+    btn.addEventListener("click", args.onClick);
+    return createRipple(btn, { speed: args.speed });
+  },
+  argTypes: {
+    backgroundColor: { control: "color" },
+    color: { control: "color" },
+    label: { control: "text" },
+    onClick: { action: "onClick" },
+    padding: { control: "text" },
+    fontSize: { control: "text" },
+    speed: { control: { type: "select" }, options: ["normal", "fast", "slow"] },
+  },
+  // 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: { onClick: fn() },
+} satisfies Meta<RippleProps>;
+
+export default meta;
+
+type RippleProps = {
+  backgroundColor: string;
+  color: string;
+  label: string;
+  onClick: (e: MouseEvent) => void;
+  padding: string;
+  fontSize: string;
+  speed: "normal" | "fast" | "slow";
+};
+
+type Story = StoryObj<RippleProps>;
+
+export const Primary: Story = {
+  args: {
+    backgroundColor: "#123489",
+    color: "white",
+    label: "Hello I am a button",
+    padding: "8px 36px",
+    fontSize: "16px",
+    speed: "slow",
+  },
+};

+ 0 - 30
src/utils/dom.ts

@@ -147,36 +147,6 @@ export function clearNode(element: Element) {
   element.parentNode!.removeChild(element);
 }
 
-const interactionKeys = ["Enter", " ", "Space"];
-
-/**
- * Adds generic, accessible interaction listeners to the passed element.  
- * All listeners have the default behavior prevented and stop propagation (for keyboard events only as long as the captured key is valid).
- * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners
- */
-export function onInteraction<TElem extends HTMLElement>(elem: TElem, listener: (evt: MouseEvent | KeyboardEvent) => void, listenerOptions?: AddEventListenerOptions) {
-  const proxListener = (e: MouseEvent | KeyboardEvent) => {
-    if(e instanceof KeyboardEvent) {
-      if(interactionKeys.includes(e.key)) {
-        e.preventDefault();
-        e.stopPropagation();
-      }
-      else return;
-    }
-    else if(e instanceof MouseEvent) {
-      e.preventDefault();
-      e.stopPropagation();
-    }
-
-    // clean up the other listener that isn't automatically removed if `once` is set
-    listenerOptions?.once && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOptions);
-    listenerOptions?.once && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOptions);
-    listener(e);
-  };
-  elem.addEventListener("click", proxListener, listenerOptions);
-  elem.addEventListener("keydown", proxListener, listenerOptions);
-}
-
 /**
  * Adds a style element to the DOM at runtime.
  * @param css The CSS stylesheet to add

+ 1 - 0
src/utils/index.ts

@@ -1,4 +1,5 @@
 export * from "./dom.js";
+export * from "./input.js";
 export * from "./logging.js";
 export * from "./misc.js";
 export * from "./NanoEmitter.js";

+ 29 - 0
src/utils/input.ts

@@ -0,0 +1,29 @@
+const interactionKeys = ["Enter", " ", "Space"];
+
+/**
+ * Adds generic, accessible interaction listeners to the passed element.  
+ * All listeners have the default behavior prevented and stop propagation (for keyboard events only as long as the captured key is valid).
+ * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners
+ */
+export function onInteraction<TElem extends HTMLElement>(elem: TElem, listener: (evt: MouseEvent | KeyboardEvent) => void, listenerOptions?: AddEventListenerOptions) {
+  const proxListener = (e: MouseEvent | KeyboardEvent) => {
+    if(e instanceof KeyboardEvent) {
+      if(interactionKeys.includes(e.key)) {
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      else return;
+    }
+    else if(e instanceof MouseEvent) {
+      e.preventDefault();
+      e.stopPropagation();
+    }
+
+    // clean up the other listener that isn't automatically removed if `once` is set
+    listenerOptions?.once && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOptions);
+    listenerOptions?.once && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOptions);
+    listener(e);
+  };
+  elem.addEventListener("click", proxListener, listenerOptions);
+  elem.addEventListener("keydown", proxListener, listenerOptions);
+}