0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

feat(lemon-ui): LemonInputSelect 2.0 (#24321)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Michael Matloka 2024-08-14 12:15:58 +02:00 committed by GitHub
parent 57793efdc6
commit ceecea49f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 333 additions and 179 deletions

View File

@ -101,7 +101,7 @@ $SKIP_SETUP_DEV || setupDev
# parallel block
# Only start webpack if not already running
nc -vz 127.0.0.1 8234 2> /dev/null || ./bin/start-frontend &
pnpm dlx cypress open --config-file cypress.e2e.config.ts &
pnpm cypress open --config-file cypress.e2e.config.ts &
uv pip install -r requirements.txt -r requirements-dev.txt
python manage.py run_autoreload_celery --type=worker &
python manage.py runserver 8080

View File

@ -70,7 +70,7 @@ describe('Events', () => {
cy.get('[data-attr="new-prop-filter-EventPropertyFilters.0"]').click()
cy.get('[data-attr=taxonomic-filter-searchfield]').click()
cy.get('[data-attr=prop-filter-event_properties-0]').click()
cy.get('[data-attr=prop-val] .LemonInput').click({ force: true })
cy.get('[data-attr=prop-val]').click({ force: true })
cy.wait('@getBrowserValues').then(() => {
cy.get('[data-attr=prop-val-0]').click()
cy.get('.DataTable').should('exist')

View File

@ -19,8 +19,8 @@ describe('Exporting Insights', () => {
cy.get('[data-attr=property-select-toggle-0]').click()
cy.get('[data-attr=taxonomic-filter-searchfield]').click()
cy.get('[data-attr=prop-filter-event_properties-1]').click({ force: true })
cy.get('[data-attr=prop-val] input').type('not-applicable')
cy.get('[data-attr=prop-val] input').type('{enter}')
cy.get('[data-attr=prop-val]').type('not-applicable')
cy.get('[data-attr=prop-val]').type('{enter}')
// Save
cy.get('[data-attr="insight-save-button"]').click()

View File

@ -97,7 +97,7 @@ describe('Surveys', () => {
// select the first property
cy.get('[data-attr="property-select-toggle-0"]').click()
cy.get('[data-attr="prop-filter-person_properties-0"]').click()
cy.get('[data-attr=prop-val] .LemonInput').click({ force: true })
cy.get('[data-attr=prop-val]').click({ force: true })
cy.get('[data-attr=prop-val-0]').click({ force: true })
cy.get('[data-attr="rollout-percentage"]').type('100')
@ -200,7 +200,7 @@ describe('Surveys', () => {
cy.contains('Add user targeting').click()
cy.get('[data-attr="property-select-toggle-0"]').click()
cy.get('[data-attr="prop-filter-person_properties-0"]').click()
cy.get('[data-attr=prop-val] .LemonInput').click({ force: true })
cy.get('[data-attr=prop-val]').click({ force: true })
cy.get('[data-attr=prop-val-0]').click({ force: true })
cy.get('[data-attr="rollout-percentage"]').type('100')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 KiB

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

After

Width:  |  Height:  |  Size: 707 KiB

View File

@ -135,7 +135,7 @@ function SidebarSearchBar({
return (
<div>
<LemonInput
ref={inputElementRef}
inputRef={inputElementRef}
type="search"
value={localSearchTerm}
onChange={(value) => {

View File

@ -121,6 +121,7 @@
}
.CardMeta__ribbon {
flex-shrink: 0;
align-self: stretch;
width: 0.375rem;
margin: 0 0.75rem 0 -0.25rem;

View File

@ -18,7 +18,7 @@ export const SearchInput = forwardRef(function SearchInput(_, ref: Ref<HTMLInput
<div className="border-b">
<LemonInput
data-attr="search-bar-input"
ref={ref}
inputRef={ref}
type="search"
className="CommandBar__input"
fullWidth

View File

@ -26,7 +26,7 @@
}
.text-link {
color: var(--text-3000);
color: var(--text-3000) !important;
}
}

View File

@ -8,7 +8,11 @@ import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect
import { formatDate, isOperatorDate, isOperatorFlag, isOperatorMulti, toString } from 'lib/utils'
import { useEffect } from 'react'
import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
import {
PROPERTY_FILTER_TYPES_WITH_ALL_TIME_SUGGESTIONS,
PROPERTY_FILTER_TYPES_WITH_TEMPORAL_SUGGESTIONS,
propertyDefinitionsModel,
} from '~/models/propertyDefinitionsModel'
import { PropertyFilterType, PropertyOperator, PropertyType } from '~/types'
export interface PropertyValueProps {
@ -133,12 +137,13 @@ export function PropertyValue({
onInputChange={onSearchTextChange}
placeholder={placeholder}
title={
!displayOptions.length
? undefined
: type === PropertyFilterType.Event
PROPERTY_FILTER_TYPES_WITH_TEMPORAL_SUGGESTIONS.includes(type)
? 'Suggested values (last 7 days)'
: 'Suggested values'
: PROPERTY_FILTER_TYPES_WITH_ALL_TIME_SUGGESTIONS.includes(type)
? 'Suggested values'
: undefined
}
popoverClassName="max-w-200"
options={displayOptions.map(({ name: _name }, index) => {
const name = toString(_name)
return {

View File

@ -69,7 +69,7 @@ const Template = (
return (
<div>
<div className="p-4 bg-default">
<div className="p-4 bg-border">
<SubscriptionsModal
{...(props as SubscriptionsModalProps)}
closeModal={() => {

View File

@ -142,7 +142,7 @@ export function TaxonomicFilter({
e.preventDefault()
}
}}
ref={searchInputRef}
inputRef={searchInputRef}
onChange={(newValue) => setSearchQuery(newValue)}
/>
</div>

View File

@ -53,7 +53,7 @@ export const Template: StoryFn<typeof LemonDialog> = (props: LemonDialogProps) =
}
return (
<div>
<div className="bg-default p-4">
<div className="bg-border p-4">
<LemonDialog {...props} inline />
</div>
<LemonButton type="primary" onClick={() => onClick()} className="mx-auto mt-2">

View File

@ -147,7 +147,7 @@ export const _FieldsWithKeaForm = (): JSX.Element => {
</LemonButton>
</div>
<pre className="rounded-lg text-white bg-default p-2 m-2">
<pre className="rounded-lg text-bg-light bg-default p-2 m-2">
formLogic.values = {JSON.stringify(formValues, null, 2)}
</pre>
</div>

View File

@ -6,6 +6,7 @@
align-items: center;
justify-content: left;
height: var(--lemon-input-height);
min-height: var(--lemon-input-height);
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
@ -17,6 +18,10 @@
border: 1px solid var(--border);
border-radius: var(--radius);
&[aria-disabled='true'] {
cursor: not-allowed;
}
&:hover:not([aria-disabled='true']),
&.LemonInput--focused:not([aria-disabled='true']) {
border-color: var(--border-bold);
@ -37,12 +42,12 @@
width: 100%;
padding: 0;
text-overflow: ellipsis;
cursor: inherit;
background: none;
border: none;
outline: none;
&:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
}
@ -97,10 +102,4 @@
width: 100%;
max-width: 100%;
}
.LemonInputSelect & {
flex-wrap: wrap;
height: auto;
min-height: var(--lemon-input-height);
}
}

View File

@ -1,5 +1,6 @@
import './LemonInput.scss'
import { useMergeRefs } from '@floating-ui/react'
import { IconEye, IconSearch, IconX } from '@posthog/icons'
import clsx from 'clsx'
import { IconEyeHidden } from 'lib/lemon-ui/icons'
@ -25,7 +26,7 @@ interface LemonInputPropsBase
| 'inputMode'
| 'pattern'
> {
ref?: React.Ref<HTMLInputElement>
inputRef?: React.Ref<HTMLInputElement>
id?: string
placeholder?: string
/** Use the danger status for invalid input. */
@ -69,7 +70,7 @@ export interface LemonInputPropsNumber
export type LemonInputProps = LemonInputPropsText | LemonInputPropsNumber
export const LemonInput = React.forwardRef<HTMLInputElement, LemonInputProps>(function _LemonInput(
export const LemonInput = React.forwardRef<HTMLDivElement, LemonInputProps>(function _LemonInput(
{
className,
onChange,
@ -86,19 +87,19 @@ export const LemonInput = React.forwardRef<HTMLInputElement, LemonInputProps>(fu
transparentBackground = false,
size = 'medium',
stopPropagation = false,
inputRef,
...props
},
ref
): JSX.Element {
const _ref = useRef<HTMLInputElement | null>(null)
const inputRef = ref || _ref
const internalInputRef = useRef<HTMLInputElement>(null)
const mergedInputRef = useMergeRefs([inputRef, internalInputRef])
const [focused, setFocused] = useState<boolean>(Boolean(props.autoFocus))
const [passwordVisible, setPasswordVisible] = useState<boolean>(false)
const focus = (): void => {
if (inputRef && 'current' in inputRef) {
inputRef.current?.focus()
}
internalInputRef.current?.focus()
setFocused(true)
}
@ -156,11 +157,12 @@ export const LemonInput = React.forwardRef<HTMLInputElement, LemonInputProps>(fu
)}
aria-disabled={props.disabled}
onClick={() => focus()}
ref={ref}
>
{prefix}
<input
className="LemonInput__input"
ref={inputRef}
ref={mergedInputRef}
type={(type === 'password' && passwordVisible ? 'text' : type) || 'text'}
value={value}
onChange={(event) => {

View File

@ -5,7 +5,24 @@ import { useState } from 'react'
import { ProfilePicture } from '../ProfilePicture'
import { LemonInputSelect, LemonInputSelectProps } from './LemonInputSelect'
const names = ['ben', 'marius', 'paul', 'tiina', 'tim', 'james', 'neil', 'tom', 'annika', 'thomas']
const names = [
'ben',
'marius',
'paul',
'tiina',
'tim',
'james',
'neil',
'tom',
'annika',
'thomas',
'eric',
'yakko',
'manoel',
'leon',
'lottie',
'charles',
]
type Story = StoryObj<typeof LemonInputSelect>
const meta: Meta<typeof LemonInputSelect> = {
@ -28,7 +45,7 @@ const meta: Meta<typeof LemonInputSelect> = {
</span>
</span>
),
label: `${x} ${x}@posthog.com>`,
label: `${capitalizeFirstLetter(x)} <${x}@posthog.com>`,
})),
},
tags: ['autodocs'],
@ -48,13 +65,13 @@ Default.args = {
export const MultipleSelect: Story = Template.bind({})
MultipleSelect.args = {
placeholder: 'Enter emails...',
placeholder: 'Pick email addresses',
mode: 'multiple',
}
export const MultipleSelectWithCustom: Story = Template.bind({})
MultipleSelectWithCustom.args = {
placeholder: 'Pick a url...',
placeholder: 'Enter URLs',
mode: 'multiple',
allowCustomValues: true,
options: [
@ -84,8 +101,7 @@ Disabled.args = {
export const Loading: Story = Template.bind({})
Loading.args = {
mode: 'single',
placeholder: 'Loading...',
options: [],
placeholder: 'Loading with options...',
loading: true,
}
Loading.parameters = {
@ -94,6 +110,19 @@ Loading.parameters = {
},
}
export const EmptyLoading: Story = Template.bind({})
EmptyLoading.args = {
mode: 'single',
placeholder: 'Loading without options...',
options: [],
loading: true,
}
EmptyLoading.parameters = {
testOptions: {
waitForLoadersToDisappear: false,
},
}
export const NoOptions: Story = Template.bind({})
NoOptions.args = {
mode: 'multiple',

View File

@ -1,9 +1,12 @@
import { Tooltip } from '@posthog/lemon-ui'
import { IconPencil } from '@posthog/icons'
import { LemonCheckbox, Tooltip } from '@posthog/lemon-ui'
import clsx from 'clsx'
import Fuse from 'fuse.js'
import { useKeyHeld } from 'lib/hooks/useKeyHeld'
import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
import { LemonSnack } from 'lib/lemon-ui/LemonSnack/LemonSnack'
import { range } from 'lib/utils'
import { useEffect, useMemo, useRef, useState } from 'react'
import { MouseEvent, useEffect, useMemo, useRef, useState } from 'react'
import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
@ -18,6 +21,8 @@ export interface LemonInputSelectOption {
key: string
label: string
labelComponent?: React.ReactNode
/** @internal */
__isInput?: boolean
}
export type LemonInputSelectProps = Pick<
@ -59,7 +64,7 @@ export function LemonInputSelect({
allowCustomValues = false,
autoFocus = false,
popoverClassName,
...props
'data-attr': dataAttr,
}: LemonInputSelectProps): JSX.Element {
const [showPopover, setShowPopover] = useState(false)
const [inputValue, _setInputValue] = useState('')
@ -69,49 +74,76 @@ export function LemonInputSelect({
const values = value ?? []
const altKeyHeld = useKeyHeld('Alt')
const fuseRef = useRef<Fuse<LemonInputSelectOption>>(
new Fuse(options, {
keys: ['label', 'key'],
})
)
const separateOnComma = allowCustomValues && mode === 'multiple'
const allOptionsMap: Map<string, LemonInputSelectOption> = useMemo(() => {
// Custom values are values that are not in the options list
const customValues = values.filter((value) => !options.some((option) => option.key === value))
// Custom values are shown as options before other options (Map guarantees preserves insertion order)
const allOptionsMap = new Map<string, LemonInputSelectOption>()
for (const customValue of customValues) {
allOptionsMap.set(customValue, { key: customValue, label: customValue })
}
for (const option of options) {
allOptionsMap.set(option.key, option)
}
// The below is a side effect (boo!) - but it's fine, since it's idempotent
fuseRef.current.setCollection(Array.from(allOptionsMap.values()))
return allOptionsMap
}, [options, values])
const visibleOptions = useMemo(() => {
const res: LemonInputSelectOption[] = []
const customValues = [...values]
// We show the input value if custom values are allowed and it's not in the list
if (allowCustomValues && inputValue && !values.includes(inputValue)) {
res.push({ key: inputValue.replace('\\,', ','), label: inputValue.replace('\\,', ',') }) // Transform escaped commas to plain commas
const ret: LemonInputSelectOption[] = []
// Show the input value if custom values are allowed and it's not in the list
if (inputValue && !values.includes(inputValue)) {
if (allowCustomValues) {
const unescapedInputValue = inputValue.replace('\\,', ',') // Transform escaped commas to plain commas
ret.push({ key: unescapedInputValue, label: unescapedInputValue, __isInput: true })
}
} else if (mode === 'single' && values.length > 0) {
// In single-select mode, show the selected value at the top
ret.push(allOptionsMap.get(values[0]) ?? { key: values[0], label: values[0] })
}
options.forEach((option) => {
// Remove from the custom values list if it's in the options
if (customValues.includes(option.key)) {
customValues.splice(customValues.indexOf(option.key), 1)
}
// Check for filtering
if (inputValue && !disableFiltering && !option.label.toLowerCase().includes(inputValue.toLowerCase())) {
return
}
res.push(option)
})
// Custom values are now added after the input value but before other options
if (customValues.length) {
customValues.forEach((value) => {
if (value !== inputValue) {
res.splice(1, 0, { key: value, label: value })
}
})
let relevantOptions: LemonInputSelectOption[]
if (!disableFiltering && inputValue) {
// If filtering is enabled and there's input, perform fuzzy search…
const results = fuseRef.current.search(inputValue)
relevantOptions = results.map((result) => result.item)
} else {
// …otherwise show all options
relevantOptions = Array.from(allOptionsMap.values())
}
// :HACKY: This is a quick fix to make the select dropdown work for large values,
// as it was getting slow when we'd load more than ~10k entries. Ideally we'd
// make this a virtualized list.
return res.slice(0, 100)
}, [options, inputValue, values])
for (const option of relevantOptions) {
if (option.key === inputValue) {
// We also don't want to show the input-based option again
continue
}
if (mode === 'single' && values.length > 0 && option.key === values[0]) {
// In single-select mode, we've already added the selected value to the top earlier
continue
}
ret.push(option)
if (ret.length >= 100) {
// :HACKY: This is a quick fix to make the select dropdown work for large values, as it was getting slow when
// we'd load more than ~10k entries. Ideally we'd make this a virtualized list.
break
}
}
return ret
}, [allOptionsMap, allowCustomValues, inputValue, mode])
// Reset the selected index when the visible options change
useEffect(() => {
setSelectedIndex(0)
}, [visibleOptions.length])
}, [visibleOptions.map((option) => option.key).join(':::')])
const setInputValue = (newValue: string): void => {
// Special case for multiple mode with custom values
@ -130,8 +162,14 @@ export function LemonInputSelect({
newValue = ''
}
if (newValue) {
// If popover was hidden due to Enter being pressed, but we kept input focus and now the user typed again,
// we should show the popover again
setShowPopover(true)
}
_setInputValue(newValue)
onInputChange?.(inputValue)
onInputChange?.(newValue)
}
const _removeItem = (item: string): void => {
@ -161,15 +199,23 @@ export function LemonInputSelect({
onChange?.(newValues)
}
const _onActionItem = (item: string): void => {
const _onActionItem = (item: string, popoverOptionClickEvent?: MouseEvent): void => {
if (altKeyHeld && allowCustomValues) {
// In this case we want to remove it if added and set input to it
if (values.includes(item)) {
_removeItem(item)
}
setInputValue(item)
_setInputValue(item)
onInputChange?.(item)
inputRef.current?.focus()
return
}
if (mode === 'single') {
setShowPopover(false)
popoverFocusRef.current = false
// Prevent propagating to Popover's onClickInside, which would set popoverFocusRef.current back to true
popoverOptionClickEvent?.stopPropagation()
}
if (values.includes(item)) {
_removeItem(item)
@ -227,8 +273,12 @@ export function LemonInputSelect({
}
}
const prefix = useMemo(
() => (
const valuesPrefix = useMemo(() => {
if (mode !== 'multiple' || values.length === 0) {
return null // Not rendering values as a suffix in single-select mode, or if there are no values selected
}
return (
// TRICKY: We don't want the popover to affect the snack buttons
<PopoverReferenceContext.Provider value={null}>
<>
@ -252,7 +302,7 @@ export function LemonInputSelect({
key={value}
title={
<>
<KeyboardShortcut option /> + click to edit, click to remove
Click to delete. Click with <KeyboardShortcut option /> to edit.
</>
}
>
@ -264,9 +314,29 @@ export function LemonInputSelect({
})}
</>
</PopoverReferenceContext.Provider>
),
[values, options, altKeyHeld, allowCustomValues]
)
)
}, [allOptionsMap, altKeyHeld, allowCustomValues])
const editButtonSuffix = useMemo(() => {
if (mode === 'multiple' || !allowCustomValues || !values.length || inputValue) {
// The edit button only applies to single-select mode with custom values allowed, when in no-input state
return null
}
return (
<PopoverReferenceContext.Provider value={null}>
<LemonButton
icon={<IconPencil />}
onClick={() => {
setInputValue(values[0])
inputRef.current?.focus()
_onFocus()
}}
tooltip="Edit current value"
noPadding
/>
</PopoverReferenceContext.Provider>
)
}, [mode, values, allowCustomValues, inputValue])
return (
<LemonDropdown
@ -283,38 +353,53 @@ export function LemonInputSelect({
e.stopPropagation()
}}
className={popoverClassName}
placement="bottom-start"
fallbackPlacements={['bottom-end', 'top-start', 'top-end']}
loadingBar={loading && visibleOptions.length > 0}
overlay={
<div className="space-y-px overflow-y-auto">
{title && <h5 className="mx-2 my-1">{title}</h5>}
{visibleOptions.length ? (
visibleOptions?.map((option, index) => {
const isHighlighted = index === selectedIndex
{visibleOptions.length > 0 ? (
visibleOptions.map((option, index) => {
const isFocused = index === selectedIndex
const isSelected = values.includes(option.key)
return (
<LemonButton
key={option.key}
type="tertiary"
size="small"
fullWidth
active={isHighlighted || values.includes(option.key)}
onClick={() => _onActionItem(option.key)}
active={isFocused || isSelected}
onClick={(e) => _onActionItem(option.key, e)}
onMouseEnter={() => setSelectedIndex(index)}
icon={
mode === 'multiple' && !option.__isInput ? (
// No pointer events, since it's only for visual feedback
<LemonCheckbox checked={isSelected} className="pointer-events-none" />
) : undefined
}
sideAction={{
// To reduce visual clutter we only show the button for the focused item,
// but we do want the side action present to make sure the layout is stable
icon: isFocused ? <IconPencil /> : undefined,
tooltip: (
<>
Edit this value <KeyboardShortcut option enter />
</>
),
onClick: () => {
setInputValue(option.key)
inputRef.current?.focus()
_onFocus()
},
}}
>
<span className="flex-1 flex items-center justify-between gap-1">
<span className="ph-no-capture">{option.labelComponent ?? option.label}</span>
{isHighlighted ? (
<span>
<KeyboardShortcut enter />{' '}
{altKeyHeld && allowCustomValues
? 'edit'
: !values.includes(option.key)
? mode === 'single'
? 'select'
: 'add'
: mode === 'single'
? 'unselect'
: 'remove'}
</span>
) : undefined}
<span className="whitespace-nowrap ph-no-capture truncate">
{!option.__isInput
? option.labelComponent ?? option.label // Regular option
: mode === 'multiple'
? `Add "${option.key}"` // Input-based option
: option.key}
</span>
</LemonButton>
)
@ -322,9 +407,10 @@ export function LemonInputSelect({
) : loading ? (
<>
{range(5).map((x) => (
<div key={x} className="flex gap-2 items-center h-10 px-1">
<LemonSkeleton.Circle className="w-6 h-6" />
<LemonSkeleton />
// 33px is the height of a regular list item
<div key={x} className="flex gap-2 items-center h-[33px] px-2">
<LemonSkeleton.Circle className="size-[18px]" />
<LemonSkeleton className="h-3.5 w-full" />
</div>
))}
</>
@ -338,20 +424,37 @@ export function LemonInputSelect({
</div>
}
>
<span className="LemonInputSelect" {...props}>
<LemonInput
ref={inputRef}
placeholder={!values.length ? placeholder : undefined}
prefix={prefix}
onFocus={_onFocus}
onBlur={_onBlur}
value={inputValue}
onChange={setInputValue}
onKeyDown={_onKeyDown}
disabled={disabled}
autoFocus={autoFocus}
/>
</span>
<LemonInput
inputRef={inputRef}
placeholder={
values.length === 0
? placeholder
: mode === 'single'
? allOptionsMap.get(values[0])?.label ?? values[0]
: allowCustomValues
? 'Add value'
: 'Pick value'
}
prefix={valuesPrefix}
suffix={editButtonSuffix}
onFocus={_onFocus}
onBlur={_onBlur}
value={inputValue}
onChange={setInputValue}
onKeyDown={_onKeyDown}
disabled={disabled}
autoFocus={autoFocus}
className={clsx(
'flex-wrap h-auto min-w-24',
// Putting button-like text styling on the single-select unfocused placeholder
mode === 'single' && values.length > 0 && 'placeholder:*:font-medium',
mode === 'single' &&
values.length > 0 &&
!showPopover &&
'cursor-pointer placeholder:*:text-default'
)}
data-attr={dataAttr}
/>
</LemonDropdown>
)
}

View File

@ -75,22 +75,6 @@ export function Customisation(): JSX.Element {
)
}
export function DarkBackground(): JSX.Element {
return (
<div className="space-y-2 bg-default p-2 rounded">
<p className="text-white">
Skeletons have a bunch of presets to help with simulating other LemonUI Components
</p>
<div className="flex items-center gap-2">
<LemonSkeleton.Circle />
<LemonSkeleton />
<LemonSkeleton.Button />
</div>
</div>
)
}
export function Repeat(): JSX.Element {
return (
<div className="space-y-2 p-2 rounded">

View File

@ -18,16 +18,15 @@ export const LemonSnack: React.FunctionComponent<LemonSnackProps & React.RefAttr
function LemonSnack({ type = 'regular', children, wrap, onClick, onClose, title, className }, ref): JSX.Element {
const isRegular = type === 'regular'
const isClickable = !!onClick
const bgColor = isRegular ? 'primary-highlight' : 'primary-alt-highlight'
return (
<span
ref={ref}
className={clsx(
'LemonSnack',
!isRegular && 'LemonSnack--pill',
`inline-flex text-primary-alt max-w-full overflow-hidden break-all items-center py-1 bg-${bgColor}`,
'inline-flex text-primary-alt max-w-full overflow-hidden break-all items-center py-1',
!wrap && 'whitespace-nowrap',
isRegular ? 'px-1.5 rounded' : 'px-4 rounded-full h-8',
isRegular
? 'bg-primary-highlight px-1.5 rounded'
: 'bg-primary-alt-highlight px-4 rounded-full h-8',
isClickable && 'cursor-pointer',
className
)}

View File

@ -1,6 +1,5 @@
.LemonTableLoader {
position: absolute;
bottom: -1px;
left: 0;
z-index: 10;
width: 100%;

View File

@ -6,14 +6,19 @@ import { CSSTransition } from 'react-transition-group'
export function LemonTableLoader({
loading = false,
tag = 'div',
placement = 'bottom',
}: {
loading?: boolean
/** @default 'div' */
tag?: 'div' | 'th'
/** @default 'bottom' */
placement?: 'bottom' | 'top'
}): JSX.Element {
return (
<CSSTransition in={loading} timeout={200} classNames="LemonTableLoader-" appear mountOnEnter unmountOnExit>
{React.createElement(tag, { className: 'LemonTableLoader' })}
{React.createElement(tag, {
className: `LemonTableLoader ${placement === 'top' ? 'top-0' : '-bottom-px'}`,
})}
</CSSTransition>
)
}

View File

@ -28,6 +28,7 @@
position: relative; // For arrow
flex-grow: 1;
max-width: 100%;
overflow: hidden;
background: var(--bg-light);
border: 1px solid var(--secondary-3000-button-border);
border-radius: var(--radius);

View File

@ -21,6 +21,8 @@ import { CLICK_OUTSIDE_BLOCK_CLASS, useOutsideClickHandler } from 'lib/hooks/use
import React, { MouseEventHandler, ReactElement, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { CSSTransition } from 'react-transition-group'
import { LemonTableLoader } from '../LemonTable/LemonTableLoader'
export interface PopoverProps {
ref?: React.MutableRefObject<HTMLDivElement | null> | React.Ref<HTMLDivElement> | null
visible: boolean
@ -38,7 +40,13 @@ export interface PopoverProps {
placement?: Placement
/** Where the popover should start relative to children if there's insufficient space for original placement. */
fallbackPlacements?: Placement[]
/** Whether the popover is actionable rather than just informative - actionable means a colored border. */
/**
* Whether to show a loading bar at the top of the overlay.
* DON'T ENABLE WHEN USING SKELETON CONTENT! Having both a skeleton AND loading bar is too much.
* Note: for (dis)appearance of the bar to be smooth, you should flip between false/true, and not undefined/true.
*/
loadingBar?: boolean
/** @deprecated */
actionable?: boolean
/** Whether the popover's width should be synced with the children's width or bigger. */
matchWidth?: boolean
@ -78,6 +86,7 @@ export const Popover = React.forwardRef<HTMLDivElement, PopoverProps>(function P
children,
referenceElement,
overlay,
loadingBar,
visible,
onClickOutside,
onClickInside,
@ -269,6 +278,10 @@ export const Popover = React.forwardRef<HTMLDivElement, PopoverProps>(function P
/>
)}
{loadingBar != null && (
<LemonTableLoader loading={loadingBar} placement="top" />
)}
{!overflowHidden ? (
<ScrollableShadows
className="Popover__content"

View File

@ -51,7 +51,7 @@ export function Sizes(): JSX.Element {
export function TextColored(): JSX.Element {
return (
<div className="bg-default p-4 text-4xl">
<Spinner textColored className="text-white" />
<Spinner textColored className="text-bg-light" />
</div>
)
}

View File

@ -23,9 +23,7 @@ const colorGroups = {
warning: ['warning-highlight', 'warning', 'warning-dark'],
success: ['success-highlight', 'success-light', 'success', 'success-dark'],
'primary-alt': ['primary-alt-highlight', 'primary-alt'],
'default (primary text)': ['default', 'default-dark'],
'muted (secondary text)': ['muted', 'muted-dark'],
'muted-alt ': ['muted-alt'],
text: ['muted', 'default'],
border: ['border', 'border-light', 'border-bold'],
light: ['white', 'light'],
}
@ -47,10 +45,7 @@ const preThousand = [
'success-dark',
'primary-alt-highlight',
'primary-alt',
'default',
'default-dark',
'muted',
'muted-dark',
'muted-alt',
'mark',
'white',

View File

@ -13,6 +13,7 @@ import {
PropertyDefinition,
PropertyDefinitionState,
PropertyDefinitionType,
PropertyFilterType,
PropertyFilterValue,
PropertyType,
} from '~/types'
@ -21,6 +22,17 @@ import type { propertyDefinitionsModelType } from './propertyDefinitionsModelTyp
export type PropertyDefinitionStorage = Record<string, PropertyDefinition | PropertyDefinitionState>
/** These property filter types get suggestions based on events filter value suggestions look just a few days back. */
export const PROPERTY_FILTER_TYPES_WITH_TEMPORAL_SUGGESTIONS = [PropertyFilterType.Event, PropertyFilterType.Feature]
/** These property filter types get suggestions based on persons and groups filter value suggestions ignore time. */
export const PROPERTY_FILTER_TYPES_WITH_ALL_TIME_SUGGESTIONS = [
PropertyFilterType.Person,
PropertyFilterType.Group,
// As of August 2024, session property values also aren't time-sensitive, but this may change
// (see RAW_SELECT_SESSION_PROP_STRING_VALUES_SQL_WITH_FILTER)
PropertyFilterType.Session,
]
// List of property definitions that are calculated on the backend. These
// are valid properties that do not exist on events.
const localProperties: PropertyDefinitionStorage = {

View File

@ -132,7 +132,7 @@ export function Login(): JSX.Element {
>
<LemonInput
type="password"
ref={passwordInputRef}
inputRef={passwordInputRef}
className="ph-ignore-input"
data-attr="password"
placeholder="••••••••••"

View File

@ -38,7 +38,7 @@ export function SignupPanel1(): JSX.Element | null {
data-attr="signup-email"
placeholder="email@yourcompany.com"
type="email"
ref={emailInputRef}
inputRef={emailInputRef}
disabled={isSignupPanel1Submitting}
/>
</LemonField>

View File

@ -82,7 +82,7 @@ export const BillingLimit = ({ product }: { product: BillingProductV2Type }): JS
<Tooltip title={error}>
<div className="max-w-36">
<LemonInput
ref={limitInputRef}
inputRef={limitInputRef}
type="number"
fullWidth={false}
status={error ? 'danger' : 'default'}

View File

@ -103,7 +103,7 @@
font-size: 0.8rem;
font-weight: 600;
color: #fff;
background-color: var(--muted-dark);
background-color: var(--tooltip-bg);
border-radius: var(--radius);
transform: translateX(-50%);
}

View File

@ -442,12 +442,12 @@ body {
.LemonButton,
.Link {
.text-link {
color: var(--text-3000);
color: var(--text-3000) !important;
}
&:hover {
.text-link {
color: var(--primary-3000);
color: var(--primary-3000) !important;
}
}
}

View File

@ -4,51 +4,59 @@
@each $name, $hex in $colors {
.text-#{$name} {
color: var(--#{$name});
color: var(--#{$name}) !important;
}
.bg-#{$name} {
background-color: var(--#{$name});
background-color: var(--#{$name}) !important;
}
.border-#{$name} {
border-color: var(--#{$name});
border-color: var(--#{$name}) !important;
}
.border-l-#{$name} {
border-left-color: var(--#{$name});
border-left-color: var(--#{$name}) !important;
}
.border-r-#{$name} {
border-right-color: var(--#{$name});
border-right-color: var(--#{$name}) !important;
}
.border-t-#{$name} {
border-top-color: var(--#{$name});
border-top-color: var(--#{$name}) !important;
}
.border-b-#{$name} {
border-bottom-color: var(--#{$name});
border-bottom-color: var(--#{$name}) !important;
}
.border-x-#{$name} {
border-right-color: var(--#{$name});
border-left-color: var(--#{$name});
border-right-color: var(--#{$name}) !important;
border-left-color: var(--#{$name}) !important;
}
.border-y-#{$name} {
border-top-color: var(--#{$name});
border-bottom-color: var(--#{$name});
border-top-color: var(--#{$name}) !important;
border-bottom-color: var(--#{$name}) !important;
}
.decoration-#{$name} {
text-decoration-color: var(--#{$name});
text-decoration-color: var(--#{$name}) !important;
}
}
@each $name, $hex in $colors {
.hover\:text-#{$name}:hover {
color: $hex;
color: $hex !important;
}
.hover\:bg-#{$name}:hover {
background-color: $hex;
background-color: $hex !important;
}
.hover\:border-#{$name}:hover {
border-color: $hex;
border-color: $hex !important;
}
}
// stylelint-disable-next-line selector-class-pattern
.placeholder\:\*\:text-default > *::placeholder {
// This is something Tailwind would generate, but we have to do Tailwind's job manually
// due to colors not being under Tailwind yet
color: var(--default);
}
// Extra utilties that Tailwind doesn't have built in
@layer utilities {
.image-pixelated {
image-rendering: pixelated;

View File

@ -23,7 +23,6 @@ $colors: (
'success': #388600,
'success-dark': #245700,
'muted': #5f5f5f,
'muted-dark': #403939,
'muted-alt': #747ea1,
'mark': hsl(42deg 93% 86% / 80%),
'white': #fff,
@ -42,7 +41,6 @@ $colors: (
'brand-key': #000,
// PostHog 3000
'default-light': #111,
'text-3000-light': #111,
'text-secondary-3000-light': rgba(#111, 0.7),
'muted-3000-light': rgba(#111, 0.6),
@ -79,7 +77,6 @@ $colors: (
'shadow-elevation-3000-light': 0 3px 0 var(--border-3000-light),
'shadow-elevation-3000-dark': 0 3px 0 var(--border-3000-dark),
'text-3000-dark': #fff,
'default-dark': #050505,
'text-secondary-3000-dark': rgba(#fff, 0.7),
'muted-3000-dark': rgba(#fff, 0.5),
'trace-3000-dark': rgba(#fff, 0.25),
@ -115,6 +112,7 @@ $colors: (
'danger-3000-button-border-hover-dark': #f54e00,
// The derived colors
// `--default` is a pre-3000 alias for "default text color" (`--text-3000` now)
'default': var(--default),
'text-3000': var(--text-3000),
'text-secondary-3000': var(--text-secondary-3000),
@ -273,7 +271,6 @@ $_lifecycle_dormant: map.get($colors, 'danger');
// defined here so that they can be shared with the toolbar
@mixin light-mode-3000-variables {
--default: var(--default-light);
--text-3000: var(--text-3000-light);
--text-secondary-3000: var(--text-secondary-3000-light);
--muted-3000: var(--muted-3000-light);
@ -312,7 +309,6 @@ $_lifecycle_dormant: map.get($colors, 'danger');
// defined here so that they can be shared with the toolbar
@mixin dark-mode-3000-variables {
--default: var(--default-dark);
--text-3000: var(--text-3000-dark);
--text-secondary-3000: var(--text-secondary-3000-dark);
--muted-3000: var(--muted-3000-dark);
@ -360,6 +356,9 @@ $_lifecycle_dormant: map.get($colors, 'danger');
@mixin common-variables {
--primary: var(--primary-3000);
--muted: var(--muted-3000);
--default: var(--text-3000);
// `--muted-alt` can be removed now, as it's the same as `--muted` after the 3000 redesign
--muted-alt: var(--muted-3000);
--primary-alt: var(--text-3000);
--border: var(--border-3000);