diff --git a/.eslintrc.js b/.eslintrc.js index 2441c77348e49..6ea14dea0d739 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -204,6 +204,10 @@ module.exports = { element: 'Collapse', message: 'use instead', }, + { + element: 'Slider', + message: 'use instead', + }, { element: 'Checkbox', message: 'use instead', diff --git a/frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png new file mode 100644 index 0000000000000..51342d621ca39 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png new file mode 100644 index 0000000000000..49a6e8a8f8abc Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-slider--basic--light.png differ diff --git a/frontend/src/lib/hooks/useEventListener.ts b/frontend/src/lib/hooks/useEventListener.ts index f24d04ebce0d2..a889fdf4e9cff 100644 --- a/frontend/src/lib/hooks/useEventListener.ts +++ b/frontend/src/lib/hooks/useEventListener.ts @@ -1,14 +1,28 @@ import { DependencyList, useEffect, useRef } from 'react' export type KeyboardEventHandler = (event: KeyboardEvent) => void +export type TouchEventHandler = (event: TouchEvent) => void +export type MouseEventHandler = (event: MouseEvent) => void export type EventHandler = (event: Event) => void export function useEventListener( - eventName: 'keyup' | 'keydown', + eventName: `key${string}`, handler: KeyboardEventHandler, element?: Element | Window | null, deps?: DependencyList ): void +export function useEventListener( + eventName: `touch${string}`, + handler: TouchEventHandler, + element?: Element | Window | null, + deps?: DependencyList +): void +export function useEventListener( + eventName: `mouse${string}`, + handler: MouseEventHandler, + element?: Element | Window | null, + deps?: DependencyList +): void export function useEventListener( eventName: string, handler: EventHandler, @@ -17,7 +31,7 @@ export function useEventListener( ): void export function useEventListener( eventName: string, - handler: EventHandler | KeyboardEventHandler, + handler: KeyboardEventHandler | TouchEventHandler | MouseEventHandler | EventHandler, element: Element | Window | null = window, deps?: DependencyList ): void { diff --git a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss index 21d86a69c33b3..6018b10f41bb7 100644 --- a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss +++ b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss @@ -87,6 +87,12 @@ max-width: 240px; } + &.LemonInput--type-number { + .LemonInput__input { + text-overflow: clip; + } + } + &.LemonInput--full-width { width: 100%; max-width: 100%; diff --git a/frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.stories.tsx b/frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.stories.tsx new file mode 100644 index 0000000000000..7b293618d7ff3 --- /dev/null +++ b/frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.stories.tsx @@ -0,0 +1,17 @@ +import { Meta } from '@storybook/react' +import { useState } from 'react' + +import { LemonSlider } from './LemonSlider' + +const meta: Meta = { + title: 'Lemon UI/Lemon Slider', + component: LemonSlider, + tags: ['autodocs'], +} +export default meta + +export function Basic(): JSX.Element { + const [value, setValue] = useState(42) + + return +} diff --git a/frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.tsx b/frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.tsx new file mode 100644 index 0000000000000..fe72960e0453d --- /dev/null +++ b/frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.tsx @@ -0,0 +1,104 @@ +import clsx from 'clsx' +import { useEventListener } from 'lib/hooks/useEventListener' +import { useRef, useState } from 'react' + +export interface LemonSliderProps { + value?: number + onChange?: (value: number) => void + min: number + max: number + /** @default 1 */ + step?: number + className?: string +} + +export function LemonSlider({ value = 0, onChange, min, max, step = 1, className }: LemonSliderProps): JSX.Element { + const trackRef = useRef(null) + const movementStartValueWithX = useRef<[number, number] | null>(null) + const [dragging, setDragging] = useState(false) + + const handleMove = (clientX: number): void => { + if (trackRef.current && movementStartValueWithX.current !== null) { + const [movementStartValue, movementStartX] = movementStartValueWithX.current + const rect = trackRef.current.getBoundingClientRect() + const adjustedWidth = rect.width - 16 // 16px = handle width + const deltaX = (clientX - movementStartX) / adjustedWidth + let newValue = movementStartValue + (max - min) * deltaX + newValue = Math.max(min, Math.min(max, newValue)) // Clamped + if (step !== undefined) { + newValue = Math.round(newValue / step) * step // Adjusted to step + } + onChange?.(newValue) + } + } + useEventListener('mousemove', (e) => { + handleMove(e.clientX) + }) + useEventListener('touchmove', (e) => { + if (e.touches.length === 1) { + handleMove(e.touches[0].clientX) + } + }) + + useEventListener('mouseup', (e) => { + if (e.button === 0) { + movementStartValueWithX.current = null + setDragging(false) + } + }) + useEventListener('touchend', () => { + movementStartValueWithX.current = null + setDragging(false) + }) + useEventListener('touchcancel', () => { + movementStartValueWithX.current = null + setDragging(false) + }) + + const proportion = Math.round(((value - min) / (max - min)) * 100) / 100 + + return ( +
+
{ + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - (rect.left + 8) // 4px = half the handle + const adjustedWidth = rect.width - 16 // 8px = handle width + let newValue = (x / adjustedWidth) * (max - min) + min + newValue = Math.max(min, Math.min(max, newValue)) // Clamped + if (step !== undefined) { + newValue = Math.round(newValue / step) * step // Adjusted to step + } + onChange?.(newValue) + }} + > +
+
+
+
{ + movementStartValueWithX.current = [value, e.clientX] + setDragging(true) + }} + onTouchStart={(e) => { + movementStartValueWithX.current = [value, e.touches[0].clientX] + setDragging(true) + }} + /> +
+ ) +} diff --git a/frontend/src/lib/lemon-ui/LemonSlider/index.ts b/frontend/src/lib/lemon-ui/LemonSlider/index.ts new file mode 100644 index 0000000000000..3d860c8215357 --- /dev/null +++ b/frontend/src/lib/lemon-ui/LemonSlider/index.ts @@ -0,0 +1 @@ +export { LemonSlider, type LemonSliderProps } from './LemonSlider' diff --git a/frontend/src/scenes/experiments/Experiment.scss b/frontend/src/scenes/experiments/Experiment.scss index 633259b80037c..53f59ba971b26 100644 --- a/frontend/src/scenes/experiments/Experiment.scss +++ b/frontend/src/scenes/experiments/Experiment.scss @@ -16,20 +16,6 @@ .experiment-preview { margin-bottom: 1rem; border-bottom: 1px solid var(--border); - - .mde-slider { - .ant-slider-handle { - border: none; - } - - .ant-slider-rail { - background-color: var(--primary-3000-highlight); - } - - .ant-slider-handle:focus { - box-shadow: 0 0 0 5px var(--primary-3000-highlight); - } - } } .variants { diff --git a/frontend/src/scenes/experiments/ExperimentPreview.tsx b/frontend/src/scenes/experiments/ExperimentPreview.tsx index 56275dd7fb31d..2066d8d73709c 100644 --- a/frontend/src/scenes/experiments/ExperimentPreview.tsx +++ b/frontend/src/scenes/experiments/ExperimentPreview.tsx @@ -1,5 +1,4 @@ import { LemonButton, LemonDivider, LemonInput, LemonModal, Tooltip } from '@posthog/lemon-ui' -import { Slider } from 'antd' import { useActions, useValues } from 'kea' import { Field, Form } from 'kea-forms' import { InsightLabel } from 'lib/components/InsightLabel' @@ -7,6 +6,7 @@ import { PropertyFilterButton } from 'lib/components/PropertyFilters/components/ import { TZLabel } from 'lib/components/TZLabel' import { dayjs } from 'lib/dayjs' import { IconInfo } from 'lib/lemon-ui/icons' +import { LemonSlider } from 'lib/lemon-ui/LemonSlider' import { humanFriendlyNumber } from 'lib/utils' import { groupFilters } from 'scenes/feature-flags/FeatureFlags' import { urls } from 'scenes/urls' @@ -112,26 +112,22 @@ export function ExperimentPreview({
-
-
- { - setExperiment({ - parameters: { - ...experiment.parameters, - minimum_detectable_effect: value, - }, - }) - }} - tipFormatter={(value) => `${value}%`} - /> -
+
+ { + setExperiment({ + parameters: { + ...experiment.parameters, + minimum_detectable_effect: value, + }, + }) + }} + className="w-1/3" + />
Roll out to{' '} + { + updateConditionSet(index, value) + }} + min={0} + max={100} + step={1} + className="ml-1.5 w-20" + /> { updateConditionSet(index, value === undefined ? 0 : value) }} - value={group.rollout_percentage != null ? group.rollout_percentage : 100} + value={group.rollout_percentage ?? 100} min={0} max={100} step="any" diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss index f830326306f98..f3d49b5da9388 100644 --- a/frontend/src/styles/global.scss +++ b/frontend/src/styles/global.scss @@ -478,6 +478,19 @@ body { --tooltip-bg: var(--bg-charcoal); --data-color-1-hover: #1d4affe5; + // Remove below once we're using Tailwind's base + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: var(--bg-light); + --tw-ring-color: var(--primary-3000); + --tw-ring-inset: ; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + touch-action: manipulation; // Disable double-tap-to-zoom on mobile, making taps slightly snappier &.posthog-3000[theme='light'] {