From 827c8631e07ef96a740d814fd85392c3713d4461 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 14:36:05 +0300 Subject: [PATCH 01/15] feat(shared): redesign fields to align with Toggle + Button system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reskin the shared field primitives to the new design language shared with the redesigned Toggle and the Button system: - Swap the legacy left-edge accent bar for a crisp, even ring on focus / invalid / readonly / password-strength, with smooth surface transitions (matching the toggle's motion). Applied to BaseField so input, password, textarea and search all update across the product. - Add a button-aligned FieldSize scale (fieldSizes.ts) that mirrors ButtonSize exactly — same heights, radii, value typography and icon sizes. A field and a button of the same `size` now line up pixel-for-pixel when they sit together in one strip, including matching "medium" icon sizes. - Thread an opt-in `fieldSize` prop through TextField / BaseFieldContainer (icons are auto-sized to match); legacy `fieldType` sizing is preserved when `fieldSize` is omitted, so existing call sites are unchanged. - Add a Storybook before/after comparison page (Field.stories.tsx) with a frozen legacy snapshot, covering states, field types, password/search and a field-vs-button size-alignment section. Co-authored-by: Cursor --- .../components/fields/BaseFieldContainer.tsx | 17 +- .../components/fields/TextField.module.css | 66 +-- .../src/components/fields/TextField.tsx | 39 +- .../src/components/fields/fieldSizes.ts | 80 ++++ .../src/components/fields/fields.module.css | 3 + .../components/fields/Field.stories.tsx | 387 ++++++++++++++++++ .../fields/legacy/LegacyField.module.css | 52 +++ .../fields/legacy/LegacyTextField.tsx | 142 +++++++ 8 files changed, 724 insertions(+), 62 deletions(-) create mode 100644 packages/shared/src/components/fields/fieldSizes.ts create mode 100644 packages/storybook/stories/components/fields/Field.stories.tsx create mode 100644 packages/storybook/stories/components/fields/legacy/LegacyField.module.css create mode 100644 packages/storybook/stories/components/fields/legacy/LegacyTextField.tsx diff --git a/packages/shared/src/components/fields/BaseFieldContainer.tsx b/packages/shared/src/components/fields/BaseFieldContainer.tsx index 30a0ec34db8..a3270111a11 100644 --- a/packages/shared/src/components/fields/BaseFieldContainer.tsx +++ b/packages/shared/src/components/fields/BaseFieldContainer.tsx @@ -1,9 +1,11 @@ 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 type { FieldSize } from './fieldSizes'; +import { fieldSizeToRadius } from './fieldSizes'; interface FieldStateProps { readOnly?: boolean; @@ -91,6 +93,7 @@ interface BaseFieldContainerProps extends FieldPlaceholderProps { className?: FieldClassName; inputId: string; fieldType?: FieldType; + fieldSize?: FieldSize; hint?: string; hintIcon?: ReactElement; saveHintSpace?: boolean; @@ -112,11 +115,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 +153,7 @@ function BaseFieldContainer( { className = {}, fieldType = 'primary', + fieldSize, readOnly, isLocked, hasInput, @@ -164,9 +168,12 @@ function BaseFieldContainer( saveHintSpace, focusInput, }: BaseFieldContainerProps, - ref?: MutableRefObject, + ref: ForwardedRef, ): ReactElement { const isSecondaryField = fieldType === 'secondary'; + const radiusClass = fieldSize + ? fieldSizeToRadius[fieldSize] + : classNames(isSecondaryField ? 'rounded-10' : 'rounded-14'); return (
@@ -193,7 +200,7 @@ function BaseFieldContainer( onClick={focusInput} className={classNames( 'relative flex', - isSecondaryField ? 'rounded-10' : 'rounded-14', + radiusClass, className.baseField, { readOnly, focused, invalid }, )} diff --git a/packages/shared/src/components/fields/TextField.module.css b/packages/shared/src/components/fields/TextField.module.css index 4b75e118ce4..01c8d219789 100644 --- a/packages/shared/src/components/fields/TextField.module.css +++ b/packages/shared/src/components/fields/TextField.module.css @@ -4,67 +4,33 @@ } } +/* + * Fields v2: validity / readiness is communicated with a crisp, even inset ring + * (no more left-edge accent bar). The ring width (2px) matches the toggle focus + * ring so fields and toggles read as one family. + */ .field { - &:hover { - box-shadow: inset 0.125rem 0 0 0 var(--theme-text-primary); - } - &:global(.focused.readOnly) { - box-shadow: inset 0.125rem 0 0 0 var(--theme-accent-blueCheese-default); + box-shadow: inset 0 0 0 0.125rem var(--theme-accent-blueCheese-default); border-color: var(--theme-accent-blueCheese-default); } - &:global(.focused) { - box-shadow: inset 0.125rem 0 0 0 var(--theme-text-primary); - border-color: var(--theme-text-primary); + &:global(.invalid) { + box-shadow: inset 0 0 0 0.125rem var(--status-error); } - &:global(.invalid) { - box-shadow: inset 0.125rem 0 0 0 var(--status-error); + &:global(.password-0), + &:global(.password-1) { + box-shadow: inset 0 0 0 0.125rem var(--status-error); } - &:global(.password-0), &:global(.password-1) { - box-shadow: inset 0.125rem 0 0 0 var(--status-error); - &:before { - content: ''; - border-radius: 14px 0px 0px 14px; - opacity: 0.24; - background: linear-gradient(270deg, rgba(252, 83, 141, 0) 0%, rgba(252, 83, 141, 1) 100%); - width: 44px; - height: 100%; - position: absolute; - left: 0; - top: 0; - } - } &:global(.password-2) { - box-shadow: inset 0.125rem 0 0 0 var(--status-warning); - &:before { - content: ''; - border-radius: 14px 0px 0px 14px; - opacity: 0.24; - background: linear-gradient(270deg, rgba(255, 142, 59, 0) 0%, rgba(255, 142, 59, 1) 100%); - width: 44px; - height: 100%; - position: absolute; - left: 0; - top: 0; - } - } + box-shadow: inset 0 0 0 0.125rem var(--status-warning); + } + &:global(.password-3) { - box-shadow: inset 0.125rem 0 0 0 var(--status-success); - &:before { - content: ''; - border-radius: 14px 0px 0px 14px; - opacity: 0.24; - background: linear-gradient(270deg, rgba(57, 229, 140, 0) 0%, rgba(107, 244, 192, 1) 100%); - width: 44px; - height: 100%; - position: absolute; - left: 0; - top: 0; - } - } + box-shadow: inset 0 0 0 0.125rem var(--status-success); + } } .field input[type='number']::-webkit-inner-spin-button, diff --git a/packages/shared/src/components/fields/TextField.tsx b/packages/shared/src/components/fields/TextField.tsx index dbbb1b4c339..113ee61d354 100644 --- a/packages/shared/src/components/fields/TextField.tsx +++ b/packages/shared/src/components/fields/TextField.tsx @@ -1,5 +1,5 @@ import type { - MutableRefObject, + ForwardedRef, ReactElement, ReactNode, SyntheticEvent, @@ -17,6 +17,8 @@ import BaseFieldContainer, { } from './BaseFieldContainer'; import type { ButtonProps } from '../buttons/Button'; import useInputFieldFunctions from '../../hooks/useInputFieldFunctions'; +import type { FieldSize } from './fieldSizes'; +import { getFieldSizeTokens } from './fieldSizes'; export interface TextFieldProps extends BaseFieldProps { progress?: string; @@ -26,6 +28,13 @@ export interface TextFieldProps extends BaseFieldProps { actionButton?: React.ReactElement>; 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; } function TextFieldComponent( @@ -47,6 +56,7 @@ function TextFieldComponent( placeholder, style, fieldType = 'primary', + fieldSize, isLocked, readOnly = isLocked, leftIcon, @@ -60,7 +70,7 @@ function TextFieldComponent( inputRef: inputRefProp, ...props }: TextFieldProps, - ref?: MutableRefObject, + ref: ForwardedRef, ): ReactElement { const { validInput, @@ -85,6 +95,17 @@ function TextFieldComponent( const invalid = validInput === false || (required && inputLength === 0); const hasValue = hasInput || !!inputRef?.current?.value?.length; const id = useId(); + const sizeTokens = fieldSize ? getFieldSizeTokens(fieldSize) : null; + const heightClass = sizeTokens?.height ?? (isSecondaryField ? 'h-9' : 'h-12'); + const withIconSize = (node: ReactNode): ReactNode => { + if (!sizeTokens || !React.isValidElement(node)) { + return node; + } + const element = node as React.ReactElement; + return React.cloneElement(element, { + size: element.props.size ?? sizeTokens.iconSize, + }); + }; return ( @@ -129,7 +151,7 @@ function TextFieldComponent( }), )} > - {leftIcon} + {withIconSize(leftIcon)} )}
{ - inputRef.current = el; - inputRefProp?.(el); + inputRef.current = el as HTMLInputElement; + if (el) { + inputRefProp?.(el); + } }} onFocus={onFocus} onBlur={(e) => { @@ -183,6 +207,7 @@ function TextFieldComponent( className={classNames( styles.input, 'self-stretch text-ellipsis', + sizeTokens?.typo, className?.input, getFieldFontColor({ readOnly, @@ -220,7 +245,7 @@ function TextFieldComponent( {maxLength - (inputLength || 0)}
)} - {rightIcon} + {withIconSize(rightIcon)} {actionButton}
); diff --git a/packages/shared/src/components/fields/fieldSizes.ts b/packages/shared/src/components/fields/fieldSizes.ts new file mode 100644 index 00000000000..fc96fe392f0 --- /dev/null +++ b/packages/shared/src/components/fields/fieldSizes.ts @@ -0,0 +1,80 @@ +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', +}; + +/** Horizontal padding per size — identical to the button horizontal padding. */ +export const fieldSizeToHorizontalPadding: Record = { + [FieldSize.XLarge]: 'px-7', + [FieldSize.Large]: 'px-6', + [FieldSize.Medium]: 'px-4', + [FieldSize.Small]: 'px-3', + [FieldSize.XSmall]: 'px-2', +}; + +/** + * Icon size per field size — identical to the button icon mapping so a "medium" + * icon is the same glyph size whether it lives in a button or a field. + */ +export const fieldSizeToIconSize: Record = { + [FieldSize.XLarge]: IconSize.XLarge, + [FieldSize.Large]: IconSize.Large, + [FieldSize.Medium]: IconSize.Medium, + [FieldSize.Small]: IconSize.Small, + [FieldSize.XSmall]: IconSize.XSmall, +}; + +export interface FieldSizeTokens { + height: string; + radius: string; + typo: string; + horizontalPadding: string; + iconSize: IconSize; +} + +export const getFieldSizeTokens = (size: FieldSize): FieldSizeTokens => ({ + height: fieldSizeToHeight[size], + radius: fieldSizeToRadius[size], + typo: fieldSizeToTypo[size], + horizontalPadding: fieldSizeToHorizontalPadding[size], + iconSize: fieldSizeToIconSize[size], +}); diff --git a/packages/shared/src/components/fields/fields.module.css b/packages/shared/src/components/fields/fields.module.css index d2de2524f7c..24d4c1a100d 100644 --- a/packages/shared/src/components/fields/fields.module.css +++ b/packages/shared/src/components/fields/fields.module.css @@ -1,5 +1,8 @@ .field { --field-placeholder-color: var(--theme-text-tertiary); + /* 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/storybook/stories/components/fields/Field.stories.tsx b/packages/storybook/stories/components/fields/Field.stories.tsx new file mode 100644 index 00000000000..679855e14f1 --- /dev/null +++ b/packages/storybook/stories/components/fields/Field.stories.tsx @@ -0,0 +1,387 @@ +import React, { useId } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +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 { FieldSize } from '@dailydotdev/shared/src/components/fields/fieldSizes'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + AtIcon, + LockIcon, + SearchIcon, + MagicIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { LegacyTextField } from './legacy/LegacyTextField'; + +const figmaUrl = + 'https://www.figma.com/design/C7n8EiXBwV1sYIEHkQHS8R/daily.dev---Design-System'; + +const meta: Meta = { + title: 'Components/Fields/Field', + component: TextField, + parameters: { + layout: 'fullscreen', + design: { type: 'figma', url: figmaUrl }, + }, + 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 side-by-side review of the previous fields and the redesigned fields. + The new fields share their motion, focus ring 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 on this page is live: click to + focus, type, hover, and tab to verify the states and interactions. Use the + theme toolbar to check light and dark. +

+
+); + +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 ; +}; + +// --- Stories --------------------------------------------------------------- + +/** + * The headline comparison: previous design on the left, redesign on the right, + * across the core text input states. Hover / focus are live on every field. + */ +export const Comparison: Story = { + render: () => ( + + + +
+
+ + } + next={} + /> + } + next={} + /> + + } + next={ + + } + /> + + } + next={} + /> + } + /> + } + next={ + } /> + } + /> +
+
+ +
+
+ + } + next={} + /> + + } + next={ + + } + /> + } + next={} + /> +
+
+ +
+
+ + } + passwordLevel={3} + hint="Strong as it gets" + /> + } + next={ + + } + /> + } + /> + } + next={} + /> +
+
+ +
+
+ {[ + { size: FieldSize.Large, button: ButtonSize.Large, label: 'Large' }, + { + size: FieldSize.Medium, + button: ButtonSize.Medium, + label: 'Medium', + }, + { size: FieldSize.Small, button: ButtonSize.Small, label: 'Small' }, + ].map(({ size, button, label }) => ( +
+ + {label} + + } + className={{ container: 'flex-1' }} + /> + +
+ ))} +
+
+
+ ), +}; + +/** Just the new field family, in its primary states. */ +export const NewDesign: Story = { + render: () => ( + +
+
+ } /> + + + + {}} + buttonSize={ButtonSize.Large} + /> +
+
+ +
+
+ + + + + } /> + + + + +
+
+
+ ), +}; diff --git a/packages/storybook/stories/components/fields/legacy/LegacyField.module.css b/packages/storybook/stories/components/fields/legacy/LegacyField.module.css new file mode 100644 index 00000000000..33931473259 --- /dev/null +++ b/packages/storybook/stories/components/fields/legacy/LegacyField.module.css @@ -0,0 +1,52 @@ +/* + * Frozen snapshot of the PREVIOUS field look (left-edge accent bar), kept only + * for the Storybook before/after comparison page. Do not use in production. + */ +.field { + --field-placeholder-color: var(--theme-text-tertiary); + + &:hover { + background: var(--theme-surface-hover); + --field-placeholder-color: var(--theme-text-primary); + box-shadow: inset 0.125rem 0 0 0 var(--theme-text-primary); + } + + &:global(.focused) { + background: transparent; + border-color: var(--theme-text-primary); + box-shadow: inset 0.125rem 0 0 0 var(--theme-text-primary); + --field-placeholder-color: var(--theme-text-quaternary); + } + + &:global(.invalid) { + box-shadow: inset 0.125rem 0 0 0 var(--status-error); + } + + &:global(.password-3) { + box-shadow: inset 0.125rem 0 0 0 var(--status-success); + + &:before { + content: ''; + border-radius: 14px 0px 0px 14px; + opacity: 0.24; + background: linear-gradient( + 270deg, + rgba(57, 229, 140, 0) 0%, + rgba(107, 244, 192, 1) 100% + ); + width: 44px; + height: 100%; + position: absolute; + left: 0; + top: 0; + } + } + + & input::placeholder { + color: var(--field-placeholder-color); + } + + & input:focus::placeholder { + color: var(--theme-text-quaternary); + } +} diff --git a/packages/storybook/stories/components/fields/legacy/LegacyTextField.tsx b/packages/storybook/stories/components/fields/legacy/LegacyTextField.tsx new file mode 100644 index 00000000000..e6bb86a31b3 --- /dev/null +++ b/packages/storybook/stories/components/fields/legacy/LegacyTextField.tsx @@ -0,0 +1,142 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useId, useState } from 'react'; +import classNames from 'classnames'; +import styles from './LegacyField.module.css'; + +/** + * Self-contained snapshot of the PREVIOUS TextField design — surface-float base + * with the left-edge accent bar on hover / focus / invalid. Kept ONLY for the + * before/after comparison story. Do not use in production code. + */ +export interface LegacyTextFieldProps { + inputId?: string; + label: string; + placeholder?: string; + fieldType?: 'primary' | 'secondary' | 'tertiary'; + defaultValue?: string; + invalid?: boolean; + disabled?: boolean; + readOnly?: boolean; + leftIcon?: ReactNode; + actionButton?: ReactNode; + hint?: string; + passwordLevel?: number; + type?: string; + className?: string; +} + +export function LegacyTextField({ + inputId, + label, + placeholder, + fieldType = 'primary', + defaultValue = '', + invalid, + disabled, + readOnly, + leftIcon, + actionButton, + hint, + passwordLevel, + type, + className, +}: LegacyTextFieldProps): ReactElement { + const generatedId = useId(); + const id = inputId ?? generatedId; + const [focused, setFocused] = useState(false); + const [value, setValue] = useState(defaultValue); + const hasValue = value.length > 0; + + const isPrimary = fieldType === 'primary'; + const isSecondary = fieldType === 'secondary'; + const isTertiary = fieldType === 'tertiary'; + + const resolvePlaceholder = (): string => { + if (isTertiary) { + return focused ? placeholder ?? '' : label; + } + if (focused || isSecondary) { + return placeholder ?? ''; + } + return label; + }; + + const fontColor = (() => { + if (disabled) { + return 'text-text-disabled'; + } + if (hasValue) { + return 'text-text-primary'; + } + if (readOnly) { + return 'text-text-quaternary'; + } + return 'text-text-tertiary'; + })(); + + return ( +
+ {isSecondary && ( + + )} +
+ {leftIcon && {leftIcon}} +
+ {isPrimary && (focused || hasValue) && ( + + )} + setFocused(true)} + onBlur={() => setFocused(false)} + onChange={(e) => setValue(e.target.value)} + className={classNames( + 'min-w-0 self-stretch bg-transparent text-ellipsis caret-text-link typo-body focus:outline-none', + fontColor, + )} + /> +
+ {actionButton} +
+ {hint && ( +
+ {hint} +
+ )} +
+ ); +} From eb8e1c6f699f686dbb3bd7807e086e07b370d52c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 15:15:32 +0300 Subject: [PATCH 02/15] feat(shared): field variants, faint border, password gradient & dropdown polish - Add a faint resting border to every field (BaseField) so fields are delineated from the page, matching the Subtle/Button v2 language. - Introduce FieldVariant (Filled / Outline) on TextField + Textarea: Filled sits on the floated surface, Outline is transparent and defined by its border with a faint hover fill. - Restore the password strength left-edge indicator + colour-graded gradient on the new field (per design preference). - Polish the dropdown popover: rounded-14 card, inset items, taller rows and smooth highlight transition. - Expand the comparison story with Variants and Dropdown & textarea sections (wrapped in a QueryClientProvider for the dropdown). Co-authored-by: Cursor --- .../src/components/dropdown/style.module.css | 12 +- .../components/fields/TextField.module.css | 60 +++++++++- .../src/components/fields/TextField.tsx | 9 ++ .../shared/src/components/fields/Textarea.tsx | 4 + .../shared/src/components/fields/common.tsx | 2 +- .../src/components/fields/fieldVariants.ts | 20 ++++ .../components/fields/Field.stories.tsx | 109 +++++++++++++++++- 7 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 packages/shared/src/components/fields/fieldVariants.ts 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/TextField.module.css b/packages/shared/src/components/fields/TextField.module.css index 01c8d219789..df69946ac47 100644 --- a/packages/shared/src/components/fields/TextField.module.css +++ b/packages/shared/src/components/fields/TextField.module.css @@ -5,9 +5,9 @@ } /* - * Fields v2: validity / readiness is communicated with a crisp, even inset ring - * (no more left-edge accent bar). The ring width (2px) matches the toggle focus - * ring so fields and toggles read as one family. + * Fields v2: focus / invalid / readonly use a crisp, even inset ring (2px, + * matching the toggle focus ring). Password strength keeps the previous + * left-edge indicator bar + colour-graded fill, which reads as a progress cue. */ .field { &:global(.focused.readOnly) { @@ -21,15 +21,63 @@ &:global(.password-0), &:global(.password-1) { - box-shadow: inset 0 0 0 0.125rem var(--status-error); + box-shadow: inset 0.125rem 0 0 0 var(--status-error); + + &:before { + content: ''; + border-radius: 0.875rem 0 0 0.875rem; + opacity: 0.24; + background: linear-gradient( + 270deg, + rgba(252, 83, 141, 0) 0%, + rgba(252, 83, 141, 1) 100% + ); + width: 2.75rem; + height: 100%; + position: absolute; + left: 0; + top: 0; + } } &:global(.password-2) { - box-shadow: inset 0 0 0 0.125rem var(--status-warning); + box-shadow: inset 0.125rem 0 0 0 var(--status-warning); + + &:before { + content: ''; + border-radius: 0.875rem 0 0 0.875rem; + opacity: 0.24; + background: linear-gradient( + 270deg, + rgba(255, 142, 59, 0) 0%, + rgba(255, 142, 59, 1) 100% + ); + width: 2.75rem; + height: 100%; + position: absolute; + left: 0; + top: 0; + } } &:global(.password-3) { - box-shadow: inset 0 0 0 0.125rem var(--status-success); + box-shadow: inset 0.125rem 0 0 0 var(--status-success); + + &:before { + content: ''; + border-radius: 0.875rem 0 0 0.875rem; + opacity: 0.24; + background: linear-gradient( + 270deg, + rgba(57, 229, 140, 0) 0%, + rgba(107, 244, 192, 1) 100% + ); + width: 2.75rem; + height: 100%; + position: absolute; + left: 0; + top: 0; + } } } diff --git a/packages/shared/src/components/fields/TextField.tsx b/packages/shared/src/components/fields/TextField.tsx index 113ee61d354..149c9878667 100644 --- a/packages/shared/src/components/fields/TextField.tsx +++ b/packages/shared/src/components/fields/TextField.tsx @@ -19,6 +19,7 @@ import type { ButtonProps } from '../buttons/Button'; import useInputFieldFunctions from '../../hooks/useInputFieldFunctions'; import type { FieldSize } from './fieldSizes'; import { getFieldSizeTokens } from './fieldSizes'; +import { FieldVariant, fieldVariantToClassName } from './fieldVariants'; export interface TextFieldProps extends BaseFieldProps { progress?: string; @@ -35,6 +36,12 @@ export interface TextFieldProps extends BaseFieldProps { * 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( @@ -57,6 +64,7 @@ function TextFieldComponent( style, fieldType = 'primary', fieldSize, + variant = FieldVariant.Filled, isLocked, readOnly = isLocked, leftIcon, @@ -130,6 +138,7 @@ function TextFieldComponent( baseField: classNames( 'flex-row items-center', styles.field, + fieldVariantToClassName[variant], className.baseField, leftIcon && 'pl-3', actionButton && 'pr-3', diff --git a/packages/shared/src/components/fields/Textarea.tsx b/packages/shared/src/components/fields/Textarea.tsx index 33916de6de1..caf2deae0b5 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 { @@ -77,6 +80,7 @@ function Textarea( baseField: classNames( 'flex-col', styles.field, + fieldVariantToClassName[variant], className.baseField, hasAdditionalSpacing ? 'pt-2' : 'pt-1', ), diff --git a/packages/shared/src/components/fields/common.tsx b/packages/shared/src/components/fields/common.tsx index e5f3688ea3f..38de3f188ad 100644 --- a/packages/shared/src/components/fields/common.tsx +++ b/packages/shared/src/components/fields/common.tsx @@ -10,7 +10,7 @@ export const FieldInput = classed( export const BaseField = classed( 'div', - 'flex px-4 overflow-hidden bg-surface-float border border-transparent cursor-text', + 'flex px-4 overflow-hidden bg-surface-float border border-border-subtlest-tertiary cursor-text', styles.field, ); 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/storybook/stories/components/fields/Field.stories.tsx b/packages/storybook/stories/components/fields/Field.stories.tsx index 679855e14f1..b0775a39345 100644 --- a/packages/storybook/stories/components/fields/Field.stories.tsx +++ b/packages/storybook/stories/components/fields/Field.stories.tsx @@ -1,10 +1,13 @@ import React, { useId } from 'react'; 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, @@ -22,6 +25,12 @@ import { LegacyTextField } from './legacy/LegacyTextField'; 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, @@ -29,6 +38,13 @@ const meta: Meta = { layout: 'fullscreen', design: { type: 'figma', url: figmaUrl }, }, + decorators: [ + (Story) => ( + + + + ), + ], tags: ['autodocs'], }; @@ -262,7 +278,7 @@ export const Comparison: Story = {
@@ -301,6 +317,97 @@ export const Comparison: Story = {
+
+
+ + } + /> + + + } + /> + + + + + + + +
+
+ +
+
+
+ + Dropdown · filled + + {}} + buttonSize={ButtonSize.Large} + className={{ + button: + 'border border-border-subtlest-tertiary bg-surface-float', + }} + /> +
+
+ + Dropdown · outline + + {}} + buttonSize={ButtonSize.Large} + className={{ + button: 'border border-border-subtlest-tertiary', + }} + /> +
+
+ + Textarea + +