Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion design-system/packages/ui/src/components/Field/Field.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion design-system/packages/ui/src/components/Field/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import styles from "./Field.module.css";

interface FieldControlProps {
"aria-describedby"?: string;
"aria-invalid"?: boolean | "true" | "false";
id?: string;
required?: boolean;
}
Expand All @@ -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;
Expand All @@ -44,6 +47,7 @@ export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field({
controlTrailing,
controlWidth = "auto",
description,
error,
horizontalGap = "md",
label,
labelAction,
Expand All @@ -58,12 +62,15 @@ export const Field = forwardRef<HTMLDivElement, FieldProps>(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,
});
Expand All @@ -76,6 +83,7 @@ export const Field = forwardRef<HTMLDivElement, FieldProps>(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}
Expand Down Expand Up @@ -115,6 +123,11 @@ export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field({
</span>
)}
</span>
{hasError && (
<span className={styles.error} data-bf-part="error" id={errorId}>
{error}
</span>
)}
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface SearchFieldProps
onClear?: MouseEventHandler<HTMLButtonElement>;
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<HTMLInputElement, SearchFieldProps>(function SearchField({
Expand All @@ -27,6 +29,7 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
onKeyDown,
onSearch,
shortcut,
trailing,
...props
}, ref) {
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
Expand All @@ -46,6 +49,17 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
/>
)
: undefined;
const endAdornment = clearAction ?? (shortcut === undefined ? undefined : (
<span aria-hidden="true" className={styles.shortcut}>{shortcut}</span>
));
const trailingContent = trailing === undefined && endAdornment === undefined
? undefined
: (
<>
{trailing}
{endAdornment}
</>
);

return (
<span className={classNames(styles.root, className)} data-bf-component="search-field">
Expand All @@ -57,9 +71,7 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
)}
onKeyDown={handleKeyDown}
ref={ref}
trailing={clearAction ?? (shortcut === undefined ? undefined : (
<span aria-hidden="true" className={styles.shortcut}>{shortcut}</span>
))}
trailing={trailingContent}
type="search"
/>
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAttributes<HTMLSpanElement>, "children"> {
Expand Down
17 changes: 17 additions & 0 deletions design-system/packages/ui/tests/field.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
18 changes: 18 additions & 0 deletions design-system/packages/ui/tests/search-field.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
6 changes: 6 additions & 0 deletions design-system/packages/ui/tests/status-pill.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ test("StatusPill exposes semantic tone and independent label anatomy", () => {
assert.match(markup, />Review</);
});

test("StatusPill renders the brand accent tone", () => {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
IconButton,
Input,
PageHeader,
StatusPill,
Switch,
TabGroup,
type TabGroupItem,
Expand All @@ -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';
Expand Down Expand Up @@ -956,7 +957,7 @@ export const RemoteConnectDialog: React.FC<RemoteConnectDialogProps> = ({
) => (
<div className="bitfun-remote-connect__connected" data-bf-component="remote-connect-dialog" data-bf-part="body" data-bf-state="connected">
<div className="bitfun-remote-connect__status" data-bf-component="remote-connect-dialog" data-bf-part="status" data-bf-state="connected">
<Badge variant="success">{t('remoteConnect.stateConnected')}</Badge>
<StatusPill tone="success">{t('remoteConnect.stateConnected')}</StatusPill>
{username && (
<span className="bitfun-remote-connect__peer-username">
{t('accountLogin.username')}: {username}
Expand Down Expand Up @@ -1014,13 +1015,13 @@ export const RemoteConnectDialog: React.FC<RemoteConnectDialogProps> = ({
</div>
</div>
)}
<Badge variant={qrCopied ? 'success' : 'warning'}>
<StatusPill tone={qrCopied ? 'success' : 'warning'}>
{qrCopied
? t('remoteConnect.urlCopied')
: connectionOwner === 'bot'
? t('remoteConnect.stateWaitingBot')
: t('remoteConnect.stateWaiting')}
</Badge>
</StatusPill>
</div>
)}
<div className="bitfun-remote-connect__pairing-details">
Expand Down Expand Up @@ -1052,11 +1053,11 @@ export const RemoteConnectDialog: React.FC<RemoteConnectDialogProps> = ({
)}
{!connectionResult.qr_url && (
<>
<Badge variant="warning">
<StatusPill tone="warning">
{connectionOwner === 'bot'
? t('remoteConnect.stateWaitingBot')
: t('remoteConnect.stateWaiting')}
</Badge>
</StatusPill>
<p className="bitfun-remote-connect__hint">
{connectionOwner === 'bot'
? t('remoteConnect.botHint')
Expand Down Expand Up @@ -1228,7 +1229,7 @@ export const RemoteConnectDialog: React.FC<RemoteConnectDialogProps> = ({
</p>,
)}
<div className="bitfun-remote-connect__status" data-bf-component="remote-connect-dialog" data-bf-part="status" data-bf-state="connected">
<Badge variant="success">{t('remoteConnect.stateConnected')}</Badge>
<StatusPill tone="success">{t('remoteConnect.stateConnected')}</StatusPill>
</div>
<div className="bitfun-remote-connect__mode-setting">
<span data-active={!botVerboseMode ? 'true' : undefined}>
Expand Down Expand Up @@ -1555,7 +1556,7 @@ export const RemoteConnectDialog: React.FC<RemoteConnectDialogProps> = ({
{statusDetail}
</span>
)}
<Badge variant={statusPositive ? 'success' : 'neutral'}>{statusLabel}</Badge>
<StatusPill tone={statusPositive ? 'success' : 'neutral'}>{statusLabel}</StatusPill>
</span>
<ChevronRight className="bitfun-remote-connect__overview-action-chevron" size={16} aria-hidden="true" />
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Button } from '@bitfun/ui';
import { Button, StatusPill } from '@bitfun/ui';
import React from 'react';
import { Badge } from '@/component-library';
import { useI18n } from '@/infrastructure/i18n';
import './RemoteConnectDisclaimer.scss';

Expand All @@ -21,9 +20,9 @@ export const RemoteConnectDisclaimerContent: React.FC<RemoteConnectDisclaimerCon
return (
<div data-bf-component="remote-connect-disclaimer" data-bf-part="root" className="bitfun-remote-disclaimer">
<div className="bitfun-remote-disclaimer__meta" data-bf-component="remote-connect-disclaimer" data-bf-part="meta">
<Badge variant={agreed ? 'success' : 'warning'}>
<StatusPill tone={agreed ? 'success' : 'warning'}>
{t(agreed ? 'remoteConnect.disclaimerStatusAgreed' : 'remoteConnect.disclaimerStatusPending')}
</Badge>
</StatusPill>
</div>

<p className="bitfun-remote-disclaimer__text" data-bf-component="remote-connect-disclaimer" data-bf-part="intro">{t('remoteConnect.disclaimerIntro')}</p>
Expand Down
Loading
Loading