Skip to content

Commit

Permalink
feat: Add LemonSlider and use it in flag rollout conditions (#19958)
Browse files Browse the repository at this point in the history
* Add `LemonSlider`

* Replace Ant `Slider` with `LemonSlider`

* Use `LemonSlider` in feature flags

* Fix slider sizing

* Update UI snapshots for `chromium` (1)

* Fix touch and add ring

* Reduce transition duration slightly

* Restore utilities.scss

* Update UI snapshots for `chromium` (1)

* Remove leftover comment

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
Twixes and github-actions[bot] authored Jan 25, 2024
1 parent a9ea061 commit 35a3c45
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 39 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ module.exports = {
element: 'Collapse',
message: 'use <LemonCollapse> instead',
},
{
element: 'Slider',
message: 'use <LemonSlider> instead',
},
{
element: 'Checkbox',
message: 'use <LemonCheckbox> instead',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 16 additions & 2 deletions frontend/src/lib/hooks/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/lemon-ui/LemonInput/LemonInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@
max-width: 240px;
}

&.LemonInput--type-number {
.LemonInput__input {
text-overflow: clip;
}
}

&.LemonInput--full-width {
width: 100%;
max-width: 100%;
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Meta } from '@storybook/react'
import { useState } from 'react'

import { LemonSlider } from './LemonSlider'

const meta: Meta<typeof LemonSlider> = {
title: 'Lemon UI/Lemon Slider',
component: LemonSlider,
tags: ['autodocs'],
}
export default meta

export function Basic(): JSX.Element {
const [value, setValue] = useState(42)

return <LemonSlider value={value} min={0} max={100} step={1} onChange={setValue} />
}
104 changes: 104 additions & 0 deletions frontend/src/lib/lemon-ui/LemonSlider/LemonSlider.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className={clsx('flex items-center relative my-2.5 min-w-16 select-none', className)}>
<div
className="w-full h-3 flex items-center cursor-pointer"
ref={trackRef}
onMouseDown={(e) => {
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)
}}
>
<div className="w-full bg-border rounded-full h-1" />
</div>
<div
className="absolute h-1 bg-primary rounded-full pointer-events-none"
// eslint-disable-next-line react/forbid-dom-props
style={{ width: `${proportion * 100}%` }}
/>
<div
className={clsx(
'absolute size-3 box-content border-2 border-bg-light rounded-full cursor-pointer bg-primary transition-shadow duration-75',
dragging ? 'ring-2 scale-90' : 'ring-0 hover:ring-2'
)}
// eslint-disable-next-line react/forbid-dom-props
style={{
left: `calc(${proportion * 100}% - ${proportion}rem)`,
}}
onMouseDown={(e) => {
movementStartValueWithX.current = [value, e.clientX]
setDragging(true)
}}
onTouchStart={(e) => {
movementStartValueWithX.current = [value, e.touches[0].clientX]
setDragging(true)
}}
/>
</div>
)
}
1 change: 1 addition & 0 deletions frontend/src/lib/lemon-ui/LemonSlider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LemonSlider, type LemonSliderProps } from './LemonSlider'
14 changes: 0 additions & 14 deletions frontend/src/scenes/experiments/Experiment.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 17 additions & 21 deletions frontend/src/scenes/experiments/ExperimentPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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'
import { PropertyFilterButton } from 'lib/components/PropertyFilters/components/PropertyFilterButton'
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'
Expand Down Expand Up @@ -112,26 +112,22 @@ export function ExperimentPreview({
<IconInfo className="ml-1 text-muted text-xl" />
</Tooltip>
</div>
<div className="flex mde-slider">
<div className="w-1/3">
<Slider
defaultValue={5}
value={minimumDetectableChange}
min={1}
max={sliderMaxValue}
trackStyle={{ background: 'var(--primary-3000)' }}
handleStyle={{ background: 'var(--primary-3000)' }}
onChange={(value) => {
setExperiment({
parameters: {
...experiment.parameters,
minimum_detectable_effect: value,
},
})
}}
tipFormatter={(value) => `${value}%`}
/>
</div>
<div className="flex gap-2">
<LemonSlider
value={minimumDetectableChange ?? 5}
min={1}
max={sliderMaxValue}
step={1}
onChange={(value) => {
setExperiment({
parameters: {
...experiment.parameters,
minimum_detectable_effect: value,
},
})
}}
className="w-1/3"
/>
<LemonInput
data-attr="min-acceptable-improvement"
type="number"
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { IconCopy, IconDelete, IconErrorOutline, IconOpenInNew, IconPlus, IconSu
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import { LemonSlider } from 'lib/lemon-ui/LemonSlider'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { Spinner } from 'lib/lemon-ui/Spinner/Spinner'
import { capitalizeFirstLetter, dateFilterToText, dateStringToComponents, humanFriendlyNumber } from 'lib/utils'
Expand Down Expand Up @@ -265,14 +266,24 @@ export function FeatureFlagReleaseConditions({
<div className="feature-flag-form-row gap-2">
<div className="flex items-center gap-1">
Roll out to{' '}
<LemonSlider
value={group.rollout_percentage ?? 100}
onChange={(value) => {
updateConditionSet(index, value)
}}
min={0}
max={100}
step={1}
className="ml-1.5 w-20"
/>
<LemonInput
data-attr="rollout-percentage"
type="number"
className="mx-2 max-w-30"
className="ml-2 mr-1.5 max-w-30"
onChange={(value): void => {
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"
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/styles/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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'] {
Expand Down

0 comments on commit 35a3c45

Please sign in to comment.