improvement(emcn): show per-chip error tooltips on invalid email chips (#4998)

* improvement(emcn): show per-chip error tooltips on invalid email chips

* improvement(emcn): fall back to generic reason for invalid email chips
This commit is contained in:
Waleed
2026-06-12 10:56:37 -07:00
committed by GitHub
parent 3db7161b1c
commit 2c4d9e98f9
5 changed files with 50 additions and 26 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ export default function PlaygroundPage() {
const [dateRangeEnd, setDateRangeEnd] = useState('')
const [tagItems, setTagItems] = useState<TagItem[]>([
{ value: 'user@example.com', isValid: true },
{ value: 'invalid-email', isValid: false },
{ value: 'invalid-email', isValid: false, error: 'Invalid email format' },
])
const toggleDarkMode = () => {
@@ -134,7 +134,14 @@ export function CredentialSets() {
return false
}
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
setEmailItems((prev) => [
...prev,
{
value: normalized,
isValid,
error: isValid ? undefined : (validation.reason ?? 'Invalid email format'),
},
])
if (isValid) {
setEmailError(null)
@@ -655,7 +655,10 @@ function AuthSelector({
setEmailError('')
onEmailsChange([...emails, normalized])
} else {
setInvalidEmailItems((prev) => [...prev, { value: normalized, isValid }])
setInvalidEmailItems((prev) => [
...prev,
{ value: normalized, isValid, error: validation.reason ?? 'Invalid email format' },
])
}
return isValid
@@ -388,12 +388,14 @@ export interface ChipModalEmailsFieldProps extends ChipModalFieldBaseProps {
/**
* Optional domain-level validator. Runs AFTER the field's internal format
* check passes. Return an error message to reject the email (added as an
* invalid chip and surfaced in the inline banner); return `null` to accept.
* invalid chip whose reason shows in a tooltip on hover); return `null`
* to accept.
*/
validate?: (email: string) => string | null
/**
* External error (e.g. server-side submit failure). Takes precedence over
* the field's internal validation banner while present.
* External error (e.g. server-side submit failure), rendered in the inline
* banner below the field. Per-email rejection reasons are shown on the
* invalid chips themselves, not here.
*/
error?: React.ReactNode
/** Auto-focus the input when the field mounts. */
@@ -561,8 +563,11 @@ function derivePlaceholderWithTags(placeholder: string): string {
/**
* Internal renderer for {@link ChipModalField} `type='emails'`. Owns the
* chip lifecycle (valid + invalid items, dedupe, inline error banner) and
* lifts only the valid email list up to the consumer via `onChange`.
* chip lifecycle (valid + invalid items, dedupe, per-chip error tooltips)
* and lifts only the valid email list up to the consumer via `onChange`.
* Each rejected entry carries its rejection reason on the chip itself,
* surfaced as a tooltip; the inline banner is reserved for the consumer's
* `error` (e.g. server-side submit failures).
*/
function ChipModalEmailsControl({
value,
@@ -576,7 +581,6 @@ function ChipModalEmailsControl({
errorId,
}: ChipModalEmailsFieldProps & { id: string; errorId: string }) {
const [items, setItems] = React.useState<TagItem[]>([])
const [internalError, setInternalError] = React.useState<string | null>(null)
/**
* Reconcile internal `items` with the consumer's `value` when the latter
@@ -600,23 +604,24 @@ function ChipModalEmailsControl({
if (!email) return false
if (items.some((item) => item.value === email)) return false
if (!quickValidateEmail(email).isValid) {
setItems((prev) => [...prev, { value: email, isValid: false }])
setInternalError(null)
const formatCheck = quickValidateEmail(email)
if (!formatCheck.isValid) {
setItems((prev) => [
...prev,
{ value: email, isValid: false, error: formatCheck.reason ?? 'Invalid email format' },
])
return false
}
const reason = validate?.(email)
if (reason) {
setItems((prev) => [...prev, { value: email, isValid: false }])
setInternalError(reason)
setItems((prev) => [...prev, { value: email, isValid: false, error: reason }])
return false
}
const next = [...items, { value: email, isValid: true }]
setItems(next)
onChange(next.filter((item) => item.isValid).map((item) => item.value))
setInternalError(null)
return true
},
[items, validate, onChange]
@@ -630,17 +635,10 @@ function ChipModalEmailsControl({
if (wasValid) {
onChange(next.filter((item) => item.isValid).map((item) => item.value))
}
setInternalError(null)
},
[items, onChange]
)
const handleInputChange = React.useCallback(() => {
setInternalError(null)
}, [])
const banner = error ?? internalError
return (
<>
<TagInput
@@ -648,16 +646,15 @@ function ChipModalEmailsControl({
items={items}
onAdd={handleAdd}
onRemove={handleRemove}
onInputChange={handleInputChange}
placeholder={placeholder}
placeholderWithTags={derivePlaceholderWithTags(placeholder)}
disabled={disabled}
autoFocus={autoFocus}
id={id}
/>
{banner && (
{error && (
<p id={errorId} role='alert' className={CHIP_MODAL_FIELD_ERROR_CLASS}>
{banner}
{error}
</p>
)}
</>
@@ -81,6 +81,11 @@ const tagInputVariants = cva(
export interface TagItem {
value: string
isValid: boolean
/**
* Why the item is invalid. Shown in a tooltip on the invalid chip (and as
* screen-reader-only text inside it). Ignored when `isValid` is true.
*/
error?: string
}
/**
@@ -162,7 +167,9 @@ const TagInputTag = React.memo(function TagInputTag({
onRemove(item.value, index, item.isValid)
}, [item.value, item.isValid, index, onRemove])
return (
const showError = !item.isValid && !!item.error
const tag = (
<ChipTag
variant='invite'
invalid={!item.isValid}
@@ -174,9 +181,19 @@ const TagInputTag = React.memo(function TagInputTag({
<span className='min-w-0 flex-1 translate-y-[0.5px] truncate font-medium font-sans text-sm leading-5'>
{item.value}
</span>
{showError && <span className='sr-only'>{item.error}</span>}
{suffix}
</ChipTag>
)
if (!showError) return tag
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{tag}</Tooltip.Trigger>
<Tooltip.Content>{item.error}</Tooltip.Content>
</Tooltip.Root>
)
})
/**