diff --git a/packages/ui/src/components/APIKeys/CreateAPIKeyForm.tsx b/packages/ui/src/components/APIKeys/CreateAPIKeyForm.tsx index 4b2caa80c1c..f4bd28ea57d 100644 --- a/packages/ui/src/components/APIKeys/CreateAPIKeyForm.tsx +++ b/packages/ui/src/components/APIKeys/CreateAPIKeyForm.tsx @@ -8,7 +8,6 @@ import { Form } from '@/ui/elements/Form'; import { FormButtons } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select'; -import { ChevronUpDown } from '@/ui/icons'; import { mqu } from '@/ui/styledSystem'; import { useFormControl } from '@/ui/utils/useFormControl'; @@ -99,7 +98,6 @@ const ExpirationSelector: React.FC = ({ selectedExpirat > ({ justifyContent: 'space-between', backgroundColor: t.colors.$colorBackground, @@ -107,12 +105,7 @@ const ExpirationSelector: React.FC = ({ selectedExpirat aria-labelledby='expiration-field' id='expiration-field' /> - ({ - paddingBlock: t.space.$1, - color: t.colors.$colorForeground, - })} - /> + ); }; diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 2a6dc9c4122..2c794a8bf4a 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -15,7 +15,7 @@ import { handleError } from '@/ui/utils/errorHandler'; import { DevOnly } from '../../common/DevOnly'; import { useCheckoutContext, usePaymentMethods } from '../../contexts'; import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Spinner, Text } from '../../customizables'; -import { ChevronUpDown, InformationCircle } from '../../icons'; +import { InformationCircle } from '../../icons'; import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem'; import * as AddPaymentMethod from '../PaymentMethods/AddPaymentMethod'; import { PaymentMethodRow } from '../PaymentMethods/PaymentMethodRow'; @@ -496,7 +496,6 @@ const ExistingPaymentMethodForm = withCardStateProvider( value={selectedPaymentMethod?.id} /> ({ justifyContent: 'space-between', backgroundColor: t.colors.$colorBackground, @@ -504,12 +503,7 @@ const ExistingPaymentMethodForm = withCardStateProvider( > {selectedPaymentMethod && } - ({ - paddingBlock: t.space.$1, - color: t.colors.$colorForeground, - })} - /> + ) : ( onChange(option.value)} referenceElement={buttonRef} renderOption={(option, _index, isSelected) => ( - ({ - width: '100%', - display: 'grid', - gridTemplateColumns: `${theme.sizes.$5} 1fr ${theme.sizes.$3}`, - columnGap: theme.space.$2, - paddingInlineStart: theme.space.$1, - paddingInlineEnd: theme.space.$1x5, - paddingBlock: theme.space.$1, - alignItems: 'center', - borderRadius: theme.radii.$md, - '&:hover, &[data-focused="true"]': { - background: common.mutedBackground(theme), - }, - })} - > - + ({ - width: theme.sizes.$5, - height: theme.sizes.$5, - objectFit: 'contain', - flexShrink: 0, - borderRadius: theme.radii.$md, - })} /> - - {option.label} - - {isSelected && ( - ({ color: theme.colors.$primary500 })} - /> - )} - + {option.label} + )} > onChange(role.value)} renderOption={(option, _index, isSelected) => ( - + + {option.label} + )} > {/*Store value inside an input in order to be accessible as form data*/} @@ -230,36 +229,3 @@ export const RoleSelect = (props: { ); }; - -type RolesListItemProps = PropsOfComponent & { - isSelected?: boolean; - option?: { - label: string; - value: string; - }; -}; - -const RolesListItem = memo((props: RolesListItemProps) => { - const { option, isSelected, sx, ...rest } = props; - return ( - ({ - width: '100%', - padding: `${theme.space.$2} ${theme.space.$4}`, - borderRadius: theme.radii.$md, - '&:hover': { - backgroundColor: theme.colors.$neutralAlpha100, - }, - '&[data-focused="true"]': { - backgroundColor: theme.colors.$neutralAlpha150, - }, - }), - sx, - ]} - {...rest} - > - {option?.label} - - ); -}); diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 73cb247af7e..364defdd20e 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -14,6 +14,7 @@ import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Card } from '@/ui/elements/Card'; +import { Select, SelectButton, SelectOption, SelectOptionList } from '@/ui/elements/Select'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { Header } from '@/ui/elements/Header'; @@ -33,7 +34,7 @@ import { withRedirectToSignInTask, } from '../../common'; import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; -import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; +import { Col, descriptors, Flow, localizationKeys, Text } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; import { useLoadingStatus } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; @@ -78,6 +79,139 @@ const useAutoFillPasskey = () => { }; }; +// PROTOTYPE: Remove before merging +const SelectOptionDemo = () => { + const [orgValue, setOrgValue] = useState('org_1'); + const [phoneValue, setPhoneValue] = useState('us'); + const [roleValue, setRoleValue] = useState('admin'); + const [defaultValue, setDefaultValue] = useState('30d'); + + const orgOptions = [ + { value: 'org_1', label: 'Acme Corp', logoUrl: 'https://img.clerk.com/placeholder' }, + { + value: 'org_2', + label: 'Globex Industries Globex Industries Globex Industries', + logoUrl: 'https://img.clerk.com/placeholder', + }, + { value: 'org_3', label: 'Initech', logoUrl: 'https://img.clerk.com/placeholder' }, + ]; + + const phoneOptions = [ + { value: 'us', label: 'United States', code: '1' }, + { value: 'gb', label: 'United Kingdom', code: '44' }, + { value: 'de', label: 'Germany', code: '49' }, + { value: 'fr', label: 'France', code: '33' }, + ]; + + const roleOptions = [ + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, + { value: 'guest', label: 'Guest' }, + ]; + + const expirationOptions = [ + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, + { value: '90d', label: '90 days' }, + { value: 'never', label: 'Never' }, + ]; + + return ( + + SelectOption Variants (prototype) + + + Image + Label (OrgSelect pattern) + + + + + Label + Detail (PhoneInput pattern) + + + + + Label only (RoleSelect pattern) + + + + + Default renderer (Checkout/APIKeys pattern) + + + + ); +}; + function SignInStartInternal(): JSX.Element { const card = useCardState(); const clerk = useClerk(); @@ -571,6 +705,8 @@ function SignInStartInternal(): JSX.Element { /> {card.error} + {/* PROTOTYPE: SelectOption variants demo — remove before merging */} + {/*TODO: extract main in its own component */} ( - ({ - '&:hover': { - backgroundColor: theme.colors.$neutralAlpha100, - }, - '&[data-focused="true"]': { - backgroundColor: theme.colors.$neutralAlpha150, - }, - })} - isSelected={isSelected} - country={option.country} - /> + + {option.country.name} + +{option.country.code} + )} onChange={option => { setIso(option.country.iso); @@ -144,13 +136,7 @@ const PhoneInputBase = forwardRef - ({ - gap: 0, - padding: `${theme.space.$0x5} 0`, - })} - /> + & { - isSelected?: boolean; - country: CountryEntry; -}; -const CountryCodeListItem = memo((props: CountryCodeListItemProps) => { - const { country, isSelected, sx, ...rest } = props; - return ( - ({ - width: '100%', - gap: theme.space.$2, - padding: `${theme.space.$1x5} ${theme.space.$4}`, - color: theme.colors.$colorForeground, - }), - sx, - ]} - {...rest} - > - - - {country.name} - - +{country.code} - - ); -}); - export const PhoneInput = forwardRef( (props, ref) => { const { __internal_country } = useClerk(); diff --git a/packages/ui/src/elements/Select.tsx b/packages/ui/src/elements/Select.tsx index 1dfb229f32c..2f84ddb2d09 100644 --- a/packages/ui/src/elements/Select.tsx +++ b/packages/ui/src/elements/Select.tsx @@ -3,9 +3,9 @@ import type { SelectId } from '@clerk/shared/types'; import type { PropsWithChildren, ReactElement, ReactNode } from 'react'; import React, { useState } from 'react'; -import { Button, descriptors, Flex, Icon, Input, Text } from '../customizables'; +import { Box, Button, descriptors, Flex, Icon, Image, Input, Text } from '../customizables'; import { usePopover, useSearchInput } from '../hooks'; -import { ChevronDown } from '../icons'; +import { Check, ChevronDown } from '../icons'; import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; import { animations, common } from '../styledSystem'; import { colors } from '../utils/colors'; @@ -32,6 +32,7 @@ type SelectProps = { elementId?: SelectId; portal?: boolean; referenceElement?: React.RefObject; + matchTriggerWidth?: boolean; }; type SelectState = Pick< @@ -51,27 +52,11 @@ type SelectState = Pick< const [SelectStateCtx, useSelectState] = createContextAndHook>('SelectState'); -const defaultRenderOption = (option: O, _index?: number) => { +const defaultRenderOption = (option: O, _index?: number, isSelected?: boolean) => { return ( - ({ - position: 'relative', - width: '100%', - padding: `${theme.space.$2} ${theme.space.$4}`, - margin: `0 ${theme.space.$1}`, - borderRadius: theme.radii.$md, - '&:hover, &[data-focused="true"]': { - background: common.mutedBackground(theme), - }, - '&::before': { - content: '""', - position: 'absolute', - inset: `calc(${theme.space.$0x5} * -1) calc(${theme.space.$1} * -1)`, - }, - })} - > - {option.label || option.value} - + + {option.label || option.value} + ); }; @@ -93,11 +78,12 @@ export const Select = withFloatingTree((props: PropsWithChildr children, portal = false, referenceElement = null, + matchTriggerWidth = false, ...rest } = props; const popoverCtx = usePopover({ autoUpdate: true, - adjustToReferenceWidth: !!referenceElement, + adjustToReferenceWidth: matchTriggerWidth || !!referenceElement, referenceElement: referenceElement, }); const togglePopover = popoverCtx.toggle; @@ -358,6 +344,8 @@ export const SelectOptionList = (props: SelectOptionListProps) => { overflowY: 'auto', maxHeight: '18vh', padding: `${theme.space.$0x5} ${theme.space.$0x5}`, + ...common.unstyledScrollbar(theme), + overflowX: 'hidden', }), containerSx, ]} @@ -404,6 +392,20 @@ export const SelectButton = ( show = selectedOption ? buttonRenderOption(selectedOption) : {placeholder}; } + // Wrap plain string/number children in a truncating Text + if (typeof show === 'string' || typeof show === 'number') { + show = ( + + {show} + + ); + } + return ( ); }; + +// --- SelectOption compound component --- + +const SelectOptionImage = (props: { src: string; alt?: string; sx?: ThemableCssProp }) => { + const { src, alt = '', sx } = props; + return ( + {alt} ({ + width: theme.sizes.$5, + height: theme.sizes.$5, + objectFit: 'contain', + flexShrink: 0, + borderRadius: theme.radii.$md, + }), + sx, + ]} + /> + ); +}; + +const SelectOptionLabel = (props: PropsOfComponent) => { + const { children, sx, ...rest } = props; + return ( + + {children} + + ); +}; + +const SelectOptionDetail = (props: PropsOfComponent) => { + const { children, sx, ...rest } = props; + return ( + + {children} + + ); +}; + +type SelectOptionRootProps = { + isSelected?: boolean; + children: ReactNode; + sx?: ThemableCssProp; + [key: string]: unknown; +}; + +const SelectOptionRoot = ({ isSelected = false, children, sx, ...rest }: SelectOptionRootProps) => { + return ( + ({ + position: 'relative', + width: '100%', + display: 'grid', + gridTemplateColumns: `1fr ${theme.sizes.$3}`, + columnGap: theme.space.$2, + paddingInlineStart: theme.space.$1, + paddingInlineEnd: theme.space.$1x5, + paddingBlock: theme.space.$1, + alignItems: 'center', + borderRadius: theme.radii.$md, + '&::before': { + content: '""', + position: 'absolute', + inset: `calc(${theme.space.$0x5} * -1) calc(${theme.space.$1} * -1)`, + }, + '&:hover, &[data-focused="true"]': { + background: common.mutedBackground(theme), + }, + '&[data-selected="true"]': { + background: common.mutedBackground(theme), + }, + '&:has([data-slot="image"])': { + gridTemplateColumns: `${theme.sizes.$5} 1fr ${theme.sizes.$3}`, + }, + '&:has([data-slot="detail"])': { + gridTemplateColumns: `${theme.sizes.$3} 1fr auto`, + '& [data-slot="check"]': { + order: -1, + }, + }, + }), + sx, + ]} + {...rest} + > + {children} + ({ + color: theme.colors.$primary500, + visibility: isSelected ? 'visible' : 'hidden', + })} + > + + + + ); +}; + +export const SelectOption = Object.assign(SelectOptionRoot, { + Image: SelectOptionImage, + Label: SelectOptionLabel, + Detail: SelectOptionDetail, +});