feat(lemon-ui): LemonInputSelect 2.0 (#24321)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.7 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 138 KiB |
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 132 KiB |
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 136 KiB |
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 133 KiB |
Before Width: | Height: | Size: 682 KiB After Width: | Height: | Size: 684 KiB |
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 682 KiB |
Before Width: | Height: | Size: 707 KiB After Width: | Height: | Size: 708 KiB |
Before Width: | Height: | Size: 705 KiB After Width: | Height: | Size: 707 KiB |
@ -135,7 +135,7 @@ function SidebarSearchBar({
|
||||
return (
|
||||
<div>
|
||||
<LemonInput
|
||||
ref={inputElementRef}
|
||||
inputRef={inputElementRef}
|
||||
type="search"
|
||||
value={localSearchTerm}
|
||||
onChange={(value) => {
|
||||
|
@ -121,6 +121,7 @@
|
||||
}
|
||||
|
||||
.CardMeta__ribbon {
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
width: 0.375rem;
|
||||
margin: 0 0.75rem 0 -0.25rem;
|
||||
|
@ -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
|
||||
|
@ -26,7 +26,7 @@
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--text-3000);
|
||||
color: var(--text-3000) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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={() => {
|
||||
|
@ -142,7 +142,7 @@ export function TaxonomicFilter({
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
ref={searchInputRef}
|
||||
inputRef={searchInputRef}
|
||||
onChange={(newValue) => setSearchQuery(newValue)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
)}
|
||||
|
@ -1,6 +1,5 @@
|
||||
.LemonTableLoader {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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 = {
|
||||
|
@ -132,7 +132,7 @@ export function Login(): JSX.Element {
|
||||
>
|
||||
<LemonInput
|
||||
type="password"
|
||||
ref={passwordInputRef}
|
||||
inputRef={passwordInputRef}
|
||||
className="ph-ignore-input"
|
||||
data-attr="password"
|
||||
placeholder="••••••••••"
|
||||
|
@ -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>
|
||||
|
@ -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'}
|
||||
|
@ -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%);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|