diff --git a/design-system/packages/ui/src/components/Field/Field.meta.ts b/design-system/packages/ui/src/components/Field/Field.meta.ts index f8e4a4820e..457515259d 100644 --- a/design-system/packages/ui/src/components/Field/Field.meta.ts +++ b/design-system/packages/ui/src/components/Field/Field.meta.ts @@ -8,6 +8,7 @@ export const fieldMeta = { props: [ { name: "label", type: "ReactNode" }, { name: "description", type: "ReactNode" }, + { name: "error", type: "ReactNode" }, { name: "labelAction", type: "ReactNode" }, { name: "controlLeading", type: "ReactNode" }, { name: "controlTrailing", type: "ReactNode" }, @@ -18,11 +19,12 @@ export const fieldMeta = { { defaultValue: "vertical", name: "orientation", type: "horizontal | vertical" }, { name: "children", type: "ReactElement" }, ], - states: ["default"], + states: ["default", "invalid"], tokens: [ "color.accent.default", "color.content.primary", "color.content.muted", + "color.status.danger.content", "layout.field.rootGap", "layout.field.horizontalGap", "layout.field.horizontalGapWide", diff --git a/design-system/packages/ui/src/components/Field/Field.module.css b/design-system/packages/ui/src/components/Field/Field.module.css index bf3e94bd45..b079be22b1 100644 --- a/design-system/packages/ui/src/components/Field/Field.module.css +++ b/design-system/packages/ui/src/components/Field/Field.module.css @@ -91,6 +91,15 @@ line-height: var(--bf-line-height-base); } + .error { + grid-column: 1 / -1; + color: var(--bf-color-status-danger-content); + font-family: var(--bf-font-family-control); + font-size: var(--bf-font-size-caption); + font-weight: var(--bf-font-weight-regular); + line-height: var(--bf-line-height-base); + } + .control { display: inline-flex; min-inline-size: 0; diff --git a/design-system/packages/ui/src/components/Field/Field.tsx b/design-system/packages/ui/src/components/Field/Field.tsx index c22f4a6137..b637314f66 100644 --- a/design-system/packages/ui/src/components/Field/Field.tsx +++ b/design-system/packages/ui/src/components/Field/Field.tsx @@ -11,6 +11,7 @@ import styles from "./Field.module.css"; interface FieldControlProps { "aria-describedby"?: string; + "aria-invalid"?: boolean | "true" | "false"; id?: string; required?: boolean; } @@ -27,6 +28,8 @@ export interface FieldProps controlTrailing?: ReactNode; controlWidth?: FieldControlWidth; description?: ReactNode; + /** Validation message rendered below the control; also marks the control invalid. */ + error?: ReactNode; horizontalGap?: FieldHorizontalGap; label: ReactNode; labelAction?: ReactNode; @@ -44,6 +47,7 @@ export const Field = forwardRef(function Field({ controlTrailing, controlWidth = "auto", description, + error, horizontalGap = "md", label, labelAction, @@ -58,12 +62,15 @@ export const Field = forwardRef(function Field({ const descriptionId = description === undefined || description === null ? undefined : `${controlId}-description`; - const describedBy = [children.props["aria-describedby"], descriptionId] + const hasError = error !== undefined && error !== null && error !== false; + const errorId = hasError ? `${controlId}-error` : undefined; + const describedBy = [children.props["aria-describedby"], descriptionId, errorId] .filter((value): value is string => Boolean(value)) .join(" ") || undefined; const isRequired = required || children.props.required === true; const control = cloneElement(children, { "aria-describedby": describedBy, + "aria-invalid": hasError ? true : children.props["aria-invalid"], id: controlId, required: isRequired || undefined, }); @@ -76,6 +83,7 @@ export const Field = forwardRef(function Field({ data-control-width={controlWidth} data-horizontal-gap={horizontalGap} data-label-width={labelWidth} + data-invalid={hasError ? "true" : undefined} data-orientation={orientation} data-required={isRequired ? "true" : "false"} ref={ref} @@ -115,6 +123,11 @@ export const Field = forwardRef(function Field({ )} + {hasError && ( + + {error} + + )} ); }); diff --git a/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts b/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts index 7491d0e314..ae361e8a80 100644 --- a/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts +++ b/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts @@ -11,6 +11,7 @@ export const searchFieldMeta = { { name: "placeholder", type: "string" }, { name: "leadingIcon", type: "ReactNode" }, { name: "shortcut", type: "ReactNode" }, + { name: "trailing", type: "ReactNode" }, { name: "clearLabel", type: "string" }, { defaultValue: "sm", name: "size", type: "sm | md | lg" }, { defaultValue: "false", name: "invalid", type: "boolean" }, diff --git a/design-system/packages/ui/src/components/SearchField/SearchField.tsx b/design-system/packages/ui/src/components/SearchField/SearchField.tsx index 8986dccf68..d4968a4664 100644 --- a/design-system/packages/ui/src/components/SearchField/SearchField.tsx +++ b/design-system/packages/ui/src/components/SearchField/SearchField.tsx @@ -17,6 +17,8 @@ export interface SearchFieldProps onClear?: MouseEventHandler; onSearch?: (value: string) => void; shortcut?: ReactNode; + /** Custom inline content before the clear action, e.g. match counts or a busy indicator. */ + trailing?: ReactNode; } export const SearchField = forwardRef(function SearchField({ @@ -27,6 +29,7 @@ export const SearchField = forwardRef(functi onKeyDown, onSearch, shortcut, + trailing, ...props }, ref) { const handleKeyDown: KeyboardEventHandler = (event) => { @@ -46,6 +49,17 @@ export const SearchField = forwardRef(functi /> ) : undefined; + const endAdornment = clearAction ?? (shortcut === undefined ? undefined : ( + + )); + const trailingContent = trailing === undefined && endAdornment === undefined + ? undefined + : ( + <> + {trailing} + {endAdornment} + + ); return ( @@ -57,9 +71,7 @@ export const SearchField = forwardRef(functi )} onKeyDown={handleKeyDown} ref={ref} - trailing={clearAction ?? (shortcut === undefined ? undefined : ( - - ))} + trailing={trailingContent} type="search" /> diff --git a/design-system/packages/ui/src/components/StatusPill/StatusPill.meta.ts b/design-system/packages/ui/src/components/StatusPill/StatusPill.meta.ts index 403121273c..376c927284 100644 --- a/design-system/packages/ui/src/components/StatusPill/StatusPill.meta.ts +++ b/design-system/packages/ui/src/components/StatusPill/StatusPill.meta.ts @@ -8,12 +8,13 @@ export const statusPillMeta = { props: [ { name: "children", type: "ReactNode" }, { name: "leading", type: "ReactNode" }, - { defaultValue: "success", name: "tone", type: "neutral | info | success | warning | danger" }, + { defaultValue: "success", name: "tone", type: "neutral | accent | info | success | warning | danger" }, ], - states: ["neutral", "info", "success", "warning", "danger"], + states: ["neutral", "accent", "info", "success", "warning", "danger"], tokens: [ "color.surface.subtle", "color.content.secondary", + "color.accent.default", "color.status.info.content", "color.status.info.surface", "color.status.success.content", diff --git a/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css b/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css index 3cbe09fe13..72632e4982 100644 --- a/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css +++ b/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css @@ -46,6 +46,11 @@ background: var(--bf-color-surface-subtle); } + .root[data-tone="accent"] { + color: var(--bf-color-accent-default); + background: color-mix(in srgb, var(--bf-color-accent-default) 12%, transparent); + } + .root[data-tone="info"] { color: var(--bf-color-status-info-content); background: var(--bf-color-status-info-surface); diff --git a/design-system/packages/ui/src/components/StatusPill/StatusPill.tsx b/design-system/packages/ui/src/components/StatusPill/StatusPill.tsx index 63038f90d5..97ddd6b612 100644 --- a/design-system/packages/ui/src/components/StatusPill/StatusPill.tsx +++ b/design-system/packages/ui/src/components/StatusPill/StatusPill.tsx @@ -6,7 +6,13 @@ import { import { classNames } from "../../internal/classNames"; import styles from "./StatusPill.module.css"; -export type StatusPillTone = "neutral" | "info" | "success" | "warning" | "danger"; +export type StatusPillTone = + | "neutral" + | "accent" + | "info" + | "success" + | "warning" + | "danger"; export interface StatusPillProps extends Omit, "children"> { diff --git a/design-system/packages/ui/tests/field.test.mjs b/design-system/packages/ui/tests/field.test.mjs index b2a387d0fb..4dfbf9911f 100644 --- a/design-system/packages/ui/tests/field.test.mjs +++ b/design-system/packages/ui/tests/field.test.mjs @@ -28,6 +28,23 @@ test("Field associates label, description, and required state with its control", assert.match(markup, /required=""/); }); +test("Field renders a validation message wired to the control accessibility contract", () => { + const markup = renderToStaticMarkup( + createElement(Field, { + error: "Name is already taken", + label: "Project name", + }, createElement(Input, { + id: "project-name", + })), + ); + + assert.match(markup, /data-invalid="true"/); + assert.match(markup, /aria-invalid="true"/); + assert.match(markup, /data-bf-part="error"[^>]*id="project-name-error"/); + assert.match(markup, /aria-describedby="project-name-error"/); + assert.match(markup, /Name is already taken/); +}); + test("Field exposes horizontal layout independently from its control", () => { const markup = renderToStaticMarkup( createElement(Field, { diff --git a/design-system/packages/ui/tests/search-field.test.mjs b/design-system/packages/ui/tests/search-field.test.mjs index 3eea9eb3c8..97b46b04dc 100644 --- a/design-system/packages/ui/tests/search-field.test.mjs +++ b/design-system/packages/ui/tests/search-field.test.mjs @@ -33,6 +33,24 @@ test("SearchField source preserves consumer key handling before Enter submission assert.match(source, /onSearch\?\.\(event\.currentTarget\.value\)/); }); +test("SearchField renders custom trailing content before the clear action", () => { + const markup = renderToStaticMarkup( + createElement(SearchField, { + "aria-label": "Search", + clearLabel: "Clear search", + onClear: () => {}, + trailing: createElement("span", { "data-part": "matches" }, "1 / 5"), + value: "query", + }), + ); + + const trailingIndex = markup.indexOf('data-part="matches"'); + const clearIndex = markup.indexOf('aria-label="Clear search"'); + assert.ok(trailingIndex >= 0); + assert.ok(clearIndex >= 0); + assert.ok(trailingIndex < clearIndex); +}); + test("SearchField exposes a labeled clear action without hiding it from assistive technology", () => { const markup = renderToStaticMarkup( createElement(SearchField, { diff --git a/design-system/packages/ui/tests/status-pill.test.mjs b/design-system/packages/ui/tests/status-pill.test.mjs index 25d196437c..0d3849bf07 100644 --- a/design-system/packages/ui/tests/status-pill.test.mjs +++ b/design-system/packages/ui/tests/status-pill.test.mjs @@ -14,6 +14,12 @@ test("StatusPill exposes semantic tone and independent label anatomy", () => { assert.match(markup, />Review { + const markup = renderToStaticMarkup(createElement(StatusPill, { tone: "accent" }, "Builtin")); + + assert.match(markup, /data-tone="accent"/); +}); + test("StatusPill keeps its optional leading indicator decorative", () => { const markup = renderToStaticMarkup(createElement( StatusPill, diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 2ae8960adf..04ec73c2d1 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -524,24 +524,24 @@ flex: 1; min-width: 0; - .bitfun-input-container { + &[data-bf-component='input'] { height: 18px; min-height: 18px; padding: 0 6px; border-radius: 4px; border-color: var(--bf-appearance-token-border-subtle); background: color-mix(in srgb, var(--bf-appearance-token-element-bg-soft) 55%, transparent); + + &:focus-within { + border-color: color-mix(in srgb, var(--bf-appearance-token-color-text-primary) 35%, var(--bf-appearance-token-border-subtle)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--bf-appearance-token-color-text-primary) 12%, transparent); + } } - .bitfun-input { + input { font-size: var(--bf-appearance-token-font-size-xs); line-height: 16px; } - - .bitfun-input-container:focus-within { - border-color: color-mix(in srgb, var(--bf-appearance-token-color-text-primary) 35%, var(--bf-appearance-token-border-subtle)); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--bf-appearance-token-color-text-primary) 12%, transparent); - } } // Reads as the next row of the list rather than a floating footer: same left diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss index 7e1f558f86..ecc78e2f43 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss @@ -253,12 +253,12 @@ align-items: stretch; } - &__path-input .bitfun-input-container, + &__path-input[data-bf-component='input'], &__description .bitfun-textarea__field { border-radius: 8px; } - &__path-input .bitfun-input-container { + &__path-input[data-bf-component='input'] { height: 32px; background: color-mix(in srgb, var(--bf-appearance-token-element-bg-base) 72%, transparent); } diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index baf0627ecc..ba6061ea3b 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -17,6 +17,7 @@ import { IconButton, Input, PageHeader, + StatusPill, Switch, TabGroup, type TabGroupItem, @@ -41,7 +42,7 @@ import { } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { getLocaleFallbackChain, type LocaleId } from '@/infrastructure/i18n/presets'; -import { Badge, Select } from '@/component-library'; +import { Select } from '@/component-library'; import { confirmWarning } from '@/infrastructure/confirm-dialog'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { api } from '@/infrastructure/api/service-api/ApiClient'; @@ -956,7 +957,7 @@ export const RemoteConnectDialog: React.FC = ({ ) => (
- {t('remoteConnect.stateConnected')} + {t('remoteConnect.stateConnected')} {username && ( {t('accountLogin.username')}: {username} @@ -1014,13 +1015,13 @@ export const RemoteConnectDialog: React.FC = ({
)} - + {qrCopied ? t('remoteConnect.urlCopied') : connectionOwner === 'bot' ? t('remoteConnect.stateWaitingBot') : t('remoteConnect.stateWaiting')} - + )}
@@ -1052,11 +1053,11 @@ export const RemoteConnectDialog: React.FC = ({ )} {!connectionResult.qr_url && ( <> - + {connectionOwner === 'bot' ? t('remoteConnect.stateWaitingBot') : t('remoteConnect.stateWaiting')} - +

{connectionOwner === 'bot' ? t('remoteConnect.botHint') @@ -1228,7 +1229,7 @@ export const RemoteConnectDialog: React.FC = ({

, )}
- {t('remoteConnect.stateConnected')} + {t('remoteConnect.stateConnected')}
@@ -1555,7 +1556,7 @@ export const RemoteConnectDialog: React.FC = ({ {statusDetail} )} - {statusLabel} + {statusLabel}