diff --git a/packages/shared/src/components/dropdown/style.module.css b/packages/shared/src/components/dropdown/style.module.css index 04734093702..5313e0b30e7 100644 --- a/packages/shared/src/components/dropdown/style.module.css +++ b/packages/shared/src/components/dropdown/style.module.css @@ -2,7 +2,7 @@ .DropdownMenuSubContent { user-select: none; z-index: 1000; - @apply py-1 px-0 m-0 bg-background-subtle rounded-12 border border-border-subtlest-secondary shadow-2; + @apply p-1.5 m-0 bg-background-subtle rounded-14 border border-border-subtlest-secondary shadow-2; animation-duration: 400ms; animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); will-change: transform, opacity; @@ -71,7 +71,15 @@ .DropdownMenuSubTrigger { border-radius: 0.625rem; @apply text-text-tertiary; - @apply flex items-center typo-footnote h-7 px-2 py-0 truncate; + @apply flex items-center typo-footnote h-8 px-2.5 py-0 truncate; + transition: background-color 0.12s linear, color 0.12s linear; +} + +.DropdownMenuItem[data-highlighted], +.DropdownMenuCheckboxItem[data-highlighted], +.DropdownMenuRadioItem[data-highlighted], +.DropdownMenuSubTrigger[data-highlighted] { + @apply text-text-primary; } .DropdownMenuItem[data-disabled], diff --git a/packages/shared/src/components/fields/BaseFieldContainer.tsx b/packages/shared/src/components/fields/BaseFieldContainer.tsx index 30a0ec34db8..9f48b334a4f 100644 --- a/packages/shared/src/components/fields/BaseFieldContainer.tsx +++ b/packages/shared/src/components/fields/BaseFieldContainer.tsx @@ -1,9 +1,10 @@ import classNames from 'classnames'; -import type { ReactElement, ReactNode, MutableRefObject } from 'react'; +import type { ReactElement, ReactNode, ForwardedRef } from 'react'; import React, { forwardRef } from 'react'; import type { FieldType, TextInputProps } from './common'; import { BaseField } from './common'; import type { IconProps } from '../Icon'; +import { FieldSize, fieldSizeToRadius } from './fieldSizes'; interface FieldStateProps { readOnly?: boolean; @@ -73,7 +74,10 @@ export const getFieldFontColor = ({ return 'text-text-quaternary'; } - return 'text-text-tertiary hover:text-text-primary'; + // Resting (empty, editable) fields read as active — secondary content on the + // floated surface, brightening to primary on hover. Tertiary here made an + // empty field look indistinguishable from the dimmed disabled state. + return 'text-text-secondary hover:text-text-primary'; }; interface InnerLabelProps extends FieldStateProps { @@ -91,6 +95,7 @@ interface BaseFieldContainerProps extends FieldPlaceholderProps { className?: FieldClassName; inputId: string; fieldType?: FieldType; + fieldSize?: FieldSize; hint?: string; hintIcon?: ReactElement; saveHintSpace?: boolean; @@ -112,11 +117,11 @@ export const getFieldPlaceholder = ({ label, }: FieldPlaceholderProps): string => { if (isQuaternaryField) { - return placeholder; + return placeholder ?? ''; } if (isTertiaryField) { - return focused ? placeholder : label; + return (focused ? placeholder : label) ?? ''; } if (focused || isSecondaryField) { @@ -150,6 +155,7 @@ function BaseFieldContainer( { className = {}, fieldType = 'primary', + fieldSize, readOnly, isLocked, hasInput, @@ -164,9 +170,17 @@ function BaseFieldContainer( saveHintSpace, focusInput, }: BaseFieldContainerProps, - ref?: MutableRefObject, + ref: ForwardedRef, ): ReactElement { const isSecondaryField = fieldType === 'secondary'; + // Radius always comes from the shared button-aligned scale, so a field's + // corner radius matches a button of the same size. The default (no explicit + // `fieldSize`) maps the compact secondary field to Small and every other + // field to Large — the same rung the default heights resolve to. + const radiusClass = + fieldSizeToRadius[ + fieldSize ?? (isSecondaryField ? FieldSize.Small : FieldSize.Large) + ]; return (
@@ -193,8 +207,9 @@ function BaseFieldContainer( onClick={focusInput} className={classNames( 'relative flex', - isSecondaryField ? 'rounded-10' : 'rounded-14', + radiusClass, className.baseField, + disabled && 'pointer-events-none opacity-32', { readOnly, focused, invalid }, )} > diff --git a/packages/shared/src/components/fields/Dropdown.tsx b/packages/shared/src/components/fields/Dropdown.tsx index 5d556808261..750ec33ceca 100644 --- a/packages/shared/src/components/fields/Dropdown.tsx +++ b/packages/shared/src/components/fields/Dropdown.tsx @@ -23,6 +23,7 @@ import { RootPortal } from '../tooltips/Portal'; import type { DrawerProps } from '../drawers'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import type { IconProps } from '../Icon'; +import { IconSize } from '../Icon'; import { Loader } from '../Loader'; export interface DropdownClassName { @@ -142,7 +143,10 @@ export function Dropdown({ size={buttonSize} disabled={disabled} className={classNames( - 'group flex w-full items-center px-3 font-normal text-text-tertiary typo-body hover:bg-surface-hover hover:text-text-primary', + // `!pl-4 !pr-2.5` overrides the Button's built-in Large padding (px-6) + // so the value lines up with the other fields' 16px text inset and the + // chevron sits tight to the right edge instead of floating 24px in. + 'group flex w-full items-center !pl-4 !pr-2.5 font-normal text-text-secondary typo-body hover:bg-surface-hover hover:text-text-primary', className?.button, iconOnly && 'items-center justify-center', )} @@ -172,13 +176,14 @@ export function Dropdown({ {iconOnly ? null : ( <> {selectedIndex >= 0 ? options[selectedIndex] : placeholder} { event.stopPropagation(); - setInput(null); + setInput(''); }; const isPrimary = fieldType === 'primary'; const isSecondary = fieldType === 'secondary'; - const sizeClass = - fieldSize === 'medium' ? 'h-10 rounded-12' : 'h-12 rounded-14'; + const isMedium = fieldSize === 'medium'; + const resolvedFieldSize = isMedium ? FieldSize.Medium : FieldSize.Large; + // Height + radius both come from the shared button-aligned scale so a search + // field lines up with a button (and every other field) of the same size. + const sizeClass = classNames( + isMedium ? 'h-10' : 'h-12', + fieldSizeToRadius[resolvedFieldSize], + ); + // Mirror the TextField icon/gap scale so a search field lines up with the + // other fields and a button of the same height. + const searchIconSize = isMedium ? IconSize.Small : IconSize.Medium; + const gapClass = isMedium ? 'gap-1' : 'gap-1.5'; return ( - } + icon={} disabled={!hasInput} /> ) : ( >; showMaxLength?: boolean; inputRef?: (input: HTMLInputElement) => void; + /** + * Button-aligned sizing. When set, the field's height, radius, value + * typography and icon size match a button of the same size exactly, so a + * field and a button can sit together in one strip and look identical. + * When omitted, the field keeps its legacy `fieldType`-driven dimensions. + */ + fieldSize?: FieldSize; + /** + * Background treatment. `Filled` (default) sits on the floated surface; + * `Outline` is transparent and defined by its border. Both share the faint + * resting border. + */ + variant?: FieldVariant; } function TextFieldComponent( @@ -47,6 +68,8 @@ function TextFieldComponent( placeholder, style, fieldType = 'primary', + fieldSize, + variant = FieldVariant.Filled, isLocked, readOnly = isLocked, leftIcon, @@ -60,7 +83,7 @@ function TextFieldComponent( inputRef: inputRefProp, ...props }: TextFieldProps, - ref?: MutableRefObject, + ref: ForwardedRef, ): ReactElement { const { validInput, @@ -85,6 +108,25 @@ function TextFieldComponent( const invalid = validInput === false || (required && inputLength === 0); const hasValue = hasInput || !!inputRef?.current?.value?.length; const id = useId(); + const typoClass = fieldSize ? fieldSizeToTypo[fieldSize] : undefined; + const defaultHeight = isSecondaryField ? 'h-9' : 'h-12'; + const heightClass = fieldSize ? fieldSizeToHeight[fieldSize] : defaultHeight; + // Resolve a button-aligned size for the icon glyph and the adornment gap even + // when no explicit `fieldSize` is set, so spacing/icon scale stays consistent + // with a button of the equivalent height. + const resolvedSize = + fieldSize ?? (isSecondaryField ? FieldSize.Small : FieldSize.Large); + const iconSize = fieldSizeToIconSize[resolvedSize]; + const gapClass = fieldSizeToGap[resolvedSize]; + const withIconSize = (node: ReactNode): ReactNode => { + if (!React.isValidElement(node)) { + return node; + } + const element = node as React.ReactElement; + return React.cloneElement(element, { + size: element.props.size ?? iconSize, + }); + }; return ( {leftIcon && ( - {leftIcon} + {withIconSize(leftIcon)} )} -
+
{isPrimaryField && (focusedHook || hasValue) && (
{maxLength && showMaxLength && ( -
+
{maxLength - (inputLength || 0)}
)} - {rightIcon} + {withIconSize(rightIcon)} {actionButton} ); diff --git a/packages/shared/src/components/fields/Textarea.tsx b/packages/shared/src/components/fields/Textarea.tsx index 33916de6de1..839008ece8d 100644 --- a/packages/shared/src/components/fields/Textarea.tsx +++ b/packages/shared/src/components/fields/Textarea.tsx @@ -9,6 +9,7 @@ import BaseFieldContainer, { getFieldPlaceholder, InnerLabel, } from './BaseFieldContainer'; +import { FieldVariant, fieldVariantToClassName } from './fieldVariants'; function Textarea( { @@ -29,9 +30,11 @@ function Textarea( maxLength = 100, rows, fieldType = 'primary', + variant = FieldVariant.Filled, ...props }: BaseFieldProps & { className?: FieldClassName; + variant?: FieldVariant; }, ref: ForwardedRef, ): ReactElement { @@ -56,7 +59,17 @@ function Textarea( const isTertiaryField = fieldType === 'tertiary'; const isQuaternaryField = fieldType === 'quaternary'; const invalid = validInput === false; - const hasAdditionalSpacing = isPrimaryField && !focused && !hasInput; + // A "title inside" caption only appears for a primary field that has a label. + // When it can't appear, nothing reserves the top, so the content should sit + // with equal padding on every side instead of the tight floating-label top. + const hasInnerLabel = isPrimaryField && !!label; + const hasAdditionalSpacing = hasInnerLabel && !focused && !hasInput; + const getPaddingClass = (): string => { + if (!hasInnerLabel) { + return 'py-4'; + } + return hasAdditionalSpacing ? 'pt-2' : 'pt-1'; + }; return ( - {isPrimaryField && (focused || hasInput) && ( + {hasInnerLabel && (focused || hasInput) && ( - + {`${inputLength || 0}/${maxLength}`} diff --git a/packages/shared/src/components/fields/common.tsx b/packages/shared/src/components/fields/common.tsx index e5f3688ea3f..95fe663c01d 100644 --- a/packages/shared/src/components/fields/common.tsx +++ b/packages/shared/src/components/fields/common.tsx @@ -10,7 +10,9 @@ export const FieldInput = classed( export const BaseField = classed( 'div', - 'flex px-4 overflow-hidden bg-surface-float border border-transparent cursor-text', + // Border width only — the resting border *color* is the Float hairline set on + // `.field` (fields.module.css) so every field matches the Button v2 weight. + 'flex px-4 overflow-hidden bg-surface-float border cursor-text', styles.field, ); diff --git a/packages/shared/src/components/fields/fieldSizes.ts b/packages/shared/src/components/fields/fieldSizes.ts new file mode 100644 index 00000000000..3633acaed21 --- /dev/null +++ b/packages/shared/src/components/fields/fieldSizes.ts @@ -0,0 +1,71 @@ +import { IconSize } from '../Icon'; + +/** + * Field sizing — deliberately mirrors `ButtonSize` (see buttons/common.ts) so a + * field and a button of the same size line up pixel-for-pixel when they sit in + * the same row/strip. The naming, heights, radii, typography and icon sizes are + * kept identical on purpose: a "medium" field is exactly as tall, as round and + * carries the same icon as a "medium" button. + */ +export enum FieldSize { + XLarge = 'xlarge', + Large = 'large', + Medium = 'medium', + Small = 'small', + XSmall = 'xsmall', +} + +/** Height token per size — identical to the button height scale. */ +export const fieldSizeToHeight: Record = { + [FieldSize.XLarge]: 'h-14', + [FieldSize.Large]: 'h-12', + [FieldSize.Medium]: 'h-10', + [FieldSize.Small]: 'h-8', + [FieldSize.XSmall]: 'h-6', +}; + +/** Corner radius per size — identical to the button radius scale. */ +export const fieldSizeToRadius: Record = { + [FieldSize.XLarge]: 'rounded-16', + [FieldSize.Large]: 'rounded-14', + [FieldSize.Medium]: 'rounded-12', + [FieldSize.Small]: 'rounded-10', + [FieldSize.XSmall]: 'rounded-8', +}; + +/** Value typography per size — matches the button label typography scale. */ +export const fieldSizeToTypo: Record = { + [FieldSize.XLarge]: 'typo-title3', + [FieldSize.Large]: 'typo-body', + [FieldSize.Medium]: 'typo-callout', + [FieldSize.Small]: 'typo-footnote', + [FieldSize.XSmall]: 'typo-caption1', +}; + +/** + * Icon size per field size. Fields sit one notch below the button icon scale: + * an icon inside an input is a left/right adornment, not the primary visual, so + * a full button-sized glyph (e.g. 32px in a Large button) reads as oversized in + * a text field. Stepping down one rung keeps the glyph optically balanced with + * the value text while still scaling in lockstep with the field height. + */ +export const fieldSizeToIconSize: Record = { + [FieldSize.XLarge]: IconSize.Large, + [FieldSize.Large]: IconSize.Medium, + [FieldSize.Medium]: IconSize.Small, + [FieldSize.Small]: IconSize.XSmall, + [FieldSize.XSmall]: IconSize.XSmall, +}; + +/** + * Gap between an adornment (icon / action) and the value — identical to the + * button gap scale (`SizeToGapV2`) so icon-to-label rhythm matches a button of + * the same size. + */ +export const fieldSizeToGap: Record = { + [FieldSize.XLarge]: 'gap-2', + [FieldSize.Large]: 'gap-1.5', + [FieldSize.Medium]: 'gap-1', + [FieldSize.Small]: 'gap-1', + [FieldSize.XSmall]: 'gap-1', +}; diff --git a/packages/shared/src/components/fields/fieldVariants.ts b/packages/shared/src/components/fields/fieldVariants.ts new file mode 100644 index 00000000000..e55382ea1d6 --- /dev/null +++ b/packages/shared/src/components/fields/fieldVariants.ts @@ -0,0 +1,20 @@ +/** + * Field background treatment. Both variants share the faint resting border that + * lives on `BaseField`, so every field is delineated; the variant only swaps + * the fill — mirroring the button system's filled vs. ghost split. + */ +export enum FieldVariant { + /** Floated surface — a subtle opacity fill over the page (the default). */ + Filled = 'filled', + /** + * Transparent — the field takes the page background and is defined by its + * border alone, with a faint fill on hover. Mirrors the Subtle/Secondary + * button look. + */ + Outline = 'outline', +} + +export const fieldVariantToClassName: Record = { + [FieldVariant.Filled]: 'bg-surface-float', + [FieldVariant.Outline]: '!bg-transparent hover:!bg-surface-hover', +}; diff --git a/packages/shared/src/components/fields/fields.module.css b/packages/shared/src/components/fields/fields.module.css index d2de2524f7c..bf829d24f4e 100644 --- a/packages/shared/src/components/fields/fields.module.css +++ b/packages/shared/src/components/fields/fields.module.css @@ -1,5 +1,20 @@ .field { - --field-placeholder-color: var(--theme-text-tertiary); + --field-placeholder-color: var(--theme-text-secondary); + /* + * Resting hairline matches the Button v2 Float variant exactly: 15% of + * `border-subtlest-primary`. This reads as a faint "filled chip with edge" + * rather than a dominant outline, so fields and Float buttons share the same + * weight. Focus/hover override this below. + */ + --field-border-color: color-mix( + in srgb, + var(--theme-border-subtlest-primary), + transparent 85% + ); + border-color: var(--field-border-color); + /* Match the toggle/button motion: quick, linear surface + ring transitions. */ + transition: background-color 0.12s linear, box-shadow 0.12s linear, + border-color 0.12s linear; &:hover { background: var(--theme-surface-hover); diff --git a/packages/shared/src/hooks/useInputFieldFunctions.ts b/packages/shared/src/hooks/useInputFieldFunctions.ts index edeb6324ba7..49d412b7219 100644 --- a/packages/shared/src/hooks/useInputFieldFunctions.ts +++ b/packages/shared/src/hooks/useInputFieldFunctions.ts @@ -1,5 +1,5 @@ import type { SyntheticEvent } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import useDebounceFn from './useDebounceFn'; import type { UseInputField, ValidInputElement } from './useInputField'; import { useInputField } from './useInputField'; @@ -34,19 +34,21 @@ function useInputFieldFunctions< onInput: baseOnInput, focusInput, setInput, - } = useInputField(value, valueChanged); - const [inputLength, setInputLength] = useState(undefined); - const [validInput, setValidInput] = useState(undefined); - const validInputRef = useRef(undefined); + } = useInputField( + value as string | number | readonly string[], + valueChanged, + ); + const [inputLength, setInputLength] = useState(undefined); + const [validInput, setValidInput] = useState(undefined); const [idleTimeout, clearIdleTimeout] = useDebounceFn(() => { - setValidInput(inputRef.current.checkValidity()); + setValidInput(inputRef.current?.checkValidity()); }, 500); useEffect(() => { - if (inputRef.current?.value) { - setInputLength(inputRef.current.value.length); - const inputValidity = inputRef.current.checkValidity(); - if (inputValidity) { + const input = inputRef.current; + if (input?.value) { + setInputLength(input.value.length); + if (input.checkValidity()) { setValidInput(true); } } @@ -60,7 +62,7 @@ function useInputFieldFunctions< } const len = event.currentTarget.value.length; setInputLength(len); - const inputValidity = inputRef.current.checkValidity(); + const inputValidity = inputRef.current?.checkValidity(); if (inputValidity) { setValidInput(true); @@ -72,17 +74,28 @@ function useInputFieldFunctions< useEffect(() => { if (validInput !== undefined) { validityChanged?.(validInput); - validInputRef.current = validInput; } // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM // eslint-disable-next-line react-hooks/exhaustive-deps }, [validInput]); + // An externally-controlled `valid` prop is authoritative whenever it is + // provided. Reflect `valid=true` immediately, but only surface an invalid + // state once the field actually has content: a pristine, untouched field that + // is technically invalid (e.g. required + empty, or a value-derived + // `valid={!!x}` / `valid={value.length > 0}`) must not flash a red border + // before the user types. Server-side and submit errors arrive with content, + // so they still show right away. useEffect(() => { - if (validInputRef.current !== undefined && valid !== undefined) { - setValidInput(valid); + if (valid === undefined) { + return; } - }, [valid]); + if (valid === false && !inputRef.current?.value) { + return; + } + setValidInput(valid); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [valid, hasInput]); const onBlur = () => { clearIdleTimeout(); diff --git a/packages/storybook/stories/components/fields/Field.stories.tsx b/packages/storybook/stories/components/fields/Field.stories.tsx new file mode 100644 index 00000000000..c8ceaf8f75f --- /dev/null +++ b/packages/storybook/stories/components/fields/Field.stories.tsx @@ -0,0 +1,1340 @@ +import React, { useId } from 'react'; +import classNames from 'classnames'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { TextField } from '@dailydotdev/shared/src/components/fields/TextField'; +import { PasswordField } from '@dailydotdev/shared/src/components/fields/PasswordField'; +import { SearchField } from '@dailydotdev/shared/src/components/fields/SearchField'; +import { Dropdown } from '@dailydotdev/shared/src/components/fields/Dropdown'; +import Textarea from '@dailydotdev/shared/src/components/fields/Textarea'; +import { FieldSize } from '@dailydotdev/shared/src/components/fields/fieldSizes'; +import { FieldVariant } from '@dailydotdev/shared/src/components/fields/fieldVariants'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + AtIcon, + LockIcon, + SearchIcon, + MagicIcon, + EyeIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { LegacyTextField } from './legacy/LegacyTextField'; +import { LegacyTextarea } from './legacy/LegacyTextarea'; + +const figmaUrl = + 'https://www.figma.com/design/C7n8EiXBwV1sYIEHkQHS8R/daily.dev---Design-System'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, +}); + +const meta: Meta = { + title: 'Components/Fields/Field', + component: TextField, + parameters: { + layout: 'fullscreen', + design: { type: 'figma', url: figmaUrl }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +// --- Layout primitives (shared with the Toggle comparison page) ------------ + +const Page = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const PageHeader = () => ( +
+ + Design system · Review + +

Fields — before & after

+

+ A complete side-by-side of every field, previous design on the left and + the redesign on the right, so the team can see exactly what changed and + play with it. The new fields share their motion, focus border and sizing + language with the redesigned Toggle and the Button system — a field and a + button of the same size line up pixel-for-pixel. Every field here is live: + click to focus, type, hover, tab. Use the theme toolbar to check light and + dark. +

+
+); + +const whatChanged = [ + 'Resting border on every field so fields are delineated from the page.', + 'Focus and validity use one 1px border: text-primary on focus, red on error, blue on a focused read-only field — no more left-edge accent bar.', + 'Sizes share the button scale (height, radius) so fields and buttons line up; icons and the icon↔text gap follow the button scale one rung down so glyphs stay balanced inside an input.', + 'Two background variants: Filled (floated surface) and Outline (transparent).', + 'Password keeps its colour-graded strength indicator; disabled fields are dimmed.', + 'Dropdown popover refreshed: rounded-14 card, inset rows, smooth highlight.', +]; + +const WhatChanged = () => ( +
+

What changed

+
    + {whatChanged.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+); + +const Section = ({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+ {children} +
+); + +const Card = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+ + {label} + +
{children}
+
+); + +const comparisonGrid = { gridTemplateColumns: '180px 1fr 1fr' } as const; + +const ComparisonHeader = () => ( +
+ + + Previous + + + New design + +
+); + +const ComparisonRow = ({ + title, + caption, + previous, + next, +}: { + title: string; + caption?: string; + previous: React.ReactNode; + next: React.ReactNode; +}) => ( +
+
+ {title} + {caption && ( + {caption} + )} +
+
+ {previous} +
+
{next}
+
+); + +// Live, interactive new TextField with the inputId/name boilerplate handled. +const NewField = ( + props: Omit< + React.ComponentProps, + 'inputId' | 'name' + > & { name?: string }, +) => { + const id = useId(); + return ; +}; + +const popoverTopics = ['Frontend', 'Backend', 'AI', 'DevOps']; + +/** + * Static preview of the dropdown popover so the old vs new card can sit side by + * side without a click. `legacy` paints the previous values (rounded-12 card, + * no inset padding, 28px rows); the default is the redesigned popover + * (rounded-14 card, 6px inset padding, 32px rows, softer highlight). + */ +const PopoverPreview = ({ legacy }: { legacy?: boolean }) => ( +
+ {popoverTopics.map((topic, i) => ( +
+ {topic} +
+ ))} +
+); + +const topics = ['Frontend', 'Backend', 'AI', 'DevOps', 'Career']; + +// Live, interactive new Textarea with the inputId/name boilerplate handled. +const LiveTextarea = ( + props: Omit, 'inputId' | 'name'> & { + name?: string; + }, +) => { + const id = useId(); + return