Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/form-fields-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflowbuilder/ui': major
'@workflowbuilder/sdk': minor
---

Input and TextArea now use `state` instead of `error`, letter-based sizes, `prefixIcon`/`suffixIcon` instead of `startAdornment`/`endAdornment`, and an optional clear action. Their shared field composition now provides associated labels and helper text.
27 changes: 17 additions & 10 deletions apps/docs/scripts/generate-ui-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ function findTypeByName(root, name, warnings) {
for (const child of node.children ?? []) walk(child);
})(root);
if (matches.length > 1 && warnings) {
warnings.push(`type name "${name}" is ambiguous (${matches.length} declarations) - the table would document whichever TypeDoc emitted first`);
warnings.push(
`type name "${name}" is ambiguous (${matches.length} declarations) - the table would document whichever TypeDoc emitted first`,
);
}
return matches[0] ?? null;
}
Expand Down Expand Up @@ -270,8 +272,7 @@ function collectVariantProps(propsTypeNames, project, byId, warnings, slug, cont
const sharedByAll = occurrences.length === perVariant.length && distinctTypes.size === 1;

// Required in every variant, else the table documents an impossible call.
const requiredEverywhere =
occurrences.length === perVariant.length && occurrences.every((o) => o.prop.required);
const requiredEverywhere = occurrences.length === perVariant.length && occurrences.every((o) => o.prop.required);
const requiredInItsVariants = !requiredEverywhere && occurrences.every((o) => o.prop.required);

const base = occurrences[0].prop;
Expand Down Expand Up @@ -299,7 +300,7 @@ function collectVariantProps(propsTypeNames, project, byId, warnings, slug, cont
return merged;
}

function extractCssVariables(directory, warnings, slug) {
function extractCssVariables(directory, cssSources, warnings, slug) {
// No directory - the entry documents an API, not a styled component.
if (!directory) return [];

Expand All @@ -316,11 +317,17 @@ function extractCssVariables(directory, warnings, slug) {

const files = globSync('**/*.css', { cwd: abs })
.filter((file) => !nestedPrefixes.some((prefix) => file.startsWith(prefix)))
.sort();
.sort()
.map((file) => path.resolve(abs, file));
for (const source of cssSources ?? []) {
const sourcePath = path.resolve(uiSource, source);
if (existsSync(sourcePath)) files.push(sourcePath);
else warnings.push(`"${slug}": CSS source ${source} does not exist`);
}
const seen = new Set();
const variables = [];
for (const file of files) {
const css = readFileSync(path.resolve(abs, file), 'utf8');
const css = readFileSync(file, 'utf8');
const re = /(--ax-public-[\w-]+)\s*:\s*([^;]*?)(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g;
let m;
while ((m = re.exec(css))) {
Expand Down Expand Up @@ -374,9 +381,9 @@ async function main() {
let props = [];
const context = { warnings, slug: component.slug };
if (Array.isArray(component.propsType)) {
props = [...collectVariantProps(component.propsType, project, byId, warnings, component.slug, context).values()].sort(
(a, b) => a.name.localeCompare(b.name),
);
props = [
...collectVariantProps(component.propsType, project, byId, warnings, component.slug, context).values(),
].sort((a, b) => a.name.localeCompare(b.name));
} else if (component.propsType) {
const typeNode = findTypeByName(project, component.propsType, warnings);
if (typeNode) {
Expand All @@ -392,7 +399,7 @@ async function main() {
name: component.name,
props,
nativeElement: context.nativeElement ?? null,
cssVariables: extractCssVariables(component.dir, warnings, component.slug),
cssVariables: extractCssVariables(component.dir, component.cssSources, warnings, component.slug),
};
}

Expand Down
20 changes: 18 additions & 2 deletions apps/docs/scripts/ui-components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ export const COMPONENTS = [
{ slug: 'collapsible', name: 'Collapsible', propsType: 'CollapsibleProps', dir: 'collapsible' },
{ slug: 'date-picker', name: 'DatePicker', propsType: 'DatePickerProps', dir: 'date-picker' },
{ slug: 'icon-switch', name: 'IconSwitch', propsType: 'IconSwitchProps', dir: 'switch/icon-switch' },
{ slug: 'input', name: 'Input', propsType: 'InputProps', dir: 'input' },
{
slug: 'input',
name: 'Input',
propsType: 'InputProps',
dir: 'input',
cssSources: [
'shared/components/field/field.module.css',
'shared/styles/field-control-height.module.css',
'shared/styles/field-control-size.module.css',
],
},
{ slug: 'menu', name: 'Menu', propsType: 'MenuProps', dir: 'menu' },
{ slug: 'modal', name: 'Modal', propsType: 'ModalProps', dir: 'modal' },
{
Expand All @@ -39,7 +49,13 @@ export const COMPONENTS = [
{ slug: 'snackbar', name: 'Snackbar', propsType: 'SnackbarProps', dir: 'snackbar' },
{ slug: 'status', name: 'Status', propsType: 'StatusProps', dir: 'status' },
{ slug: 'switch', name: 'Switch', propsType: 'BaseSwitchProps', dir: 'switch' },
{ slug: 'text-area', name: 'TextArea', propsType: 'TextAreaProps', dir: 'text-area' },
{
slug: 'text-area',
name: 'TextArea',
propsType: 'TextAreaProps',
dir: 'text-area',
cssSources: ['shared/components/field/field.module.css', 'shared/styles/field-control-size.module.css'],
},
{ slug: 'tooltip', name: 'Tooltip', propsType: 'TooltipProps', dir: 'tooltip' },
// Diagram components.
{ slug: 'node-icon', name: 'NodeIcon', propsType: 'NodeIconProps', dir: 'node/node-icon' },
Expand Down
38 changes: 36 additions & 2 deletions apps/docs/src/components/ui-examples/input.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,48 @@
import { MagnifyingGlass } from '@phosphor-icons/react';
import { Input } from '@workflowbuilder/ui';
import { useState } from 'react';

import { ComponentPreview } from './component-preview';

export function InputExample() {
const [value, setValue] = useState('');
const [search, setSearch] = useState('');
const [projectName, setProjectName] = useState('');
const [displayName, setDisplayName] = useState('');

return (
<ComponentPreview>
<Input placeholder="Type something" value={value} onChange={(event) => setValue(event.target.value)} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--wb-space-100)' }}>
<Input
size="l"
label="Search"
prefixIcon={<MagnifyingGlass />}
placeholder="Large input"
value={search}
onChange={(event) => setSearch(event.target.value)}
onClear={() => setSearch('')}
clearLabel="Clear search"
/>
<Input
size="m"
state="critical"
label="Project name"
helperText="Enter at least three characters"
isRequired
placeholder="Critical input"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
/>
<Input
size="s"
state="success"
label="Display name"
helperText="Name is available"
placeholder="Successful input"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
<Input label="Identifier" size="xs" state="read-only" value="Read-only value" />
</div>
</ComponentPreview>
);
}
26 changes: 24 additions & 2 deletions apps/docs/src/components/ui-examples/text-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,33 @@ import { useState } from 'react';
import { ComponentPreview } from './component-preview';

export function TextAreaExample() {
const [value, setValue] = useState('');
const [description, setDescription] = useState('');
const [summary, setSummary] = useState('');

return (
<ComponentPreview>
<TextArea placeholder="Multi-line input" value={value} onChange={(event) => setValue(event.target.value)} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--wb-space-100)' }}>
<TextArea
size="l"
label="Description"
placeholder="Large text area"
value={description}
onChange={(event) => setDescription(event.target.value)}
onClear={() => setDescription('')}
clearLabel="Clear description"
/>
<TextArea
size="m"
state="critical"
label="Summary"
helperText="A summary is required"
isRequired
placeholder="Critical text area"
value={summary}
onChange={(event) => setSummary(event.target.value)}
/>
<TextArea label="Notes" size="s" state="read-only" value="Read-only value" />
</div>
</ComponentPreview>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ live, interactive example plus the component's props and CSS variables.
- [Checkbox](/ui-library/ui-components/checkbox/) - Checked, unchecked, and indeterminate states.
- [Collapsible](/ui-library/ui-components/collapsible/) - Expand and collapse a content section.
- [DatePicker](/ui-library/ui-components/date-picker/) - Date selection with a calendar popover.
- [Input](/ui-library/ui-components/input/) - Text input with adornments and an error state.
- [Input](/ui-library/ui-components/input/) - Text input with icons and explicit field states.
- [Menu](/ui-library/ui-components/menu/) - Popup menu for dropdowns.
- [Modal](/ui-library/ui-components/modal/) - Dialog overlay with a backdrop.
- [NavButton](/ui-library/ui-components/nav-button/) - Compact icon / label navigation button.
Expand Down
23 changes: 20 additions & 3 deletions apps/docs/src/content/docs/ui-library/ui-components/input.mdx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
---
title: Input
description: A single-line text field with optional adornments and an error state.
description: A single-line text field with icons, explicit states, and four sizes.
---

import CssVariablesTable from '../../../../components/api/css-variables-table.astro';
import PropsTable from '../../../../components/api/props-table.astro';
import { InputExample } from '../../../../components/ui-examples/input';

An `Input` is a single-line text field for collecting short, free-form text. It
supports start and end adornments, an error state, and the standard input sizes.
supports prefix and suffix icons, a clear affordance, four letter sizes, and the
`default`, `critical`, `success`, and `read-only` states. A read-only field stays
focusable so its value can be selected and copied.

<InputExample client:visible />

Expand All @@ -20,7 +22,22 @@ import { useState } from 'react';

function Example() {
const [value, setValue] = useState('');
return <Input placeholder="Type something" value={value} onChange={(event) => setValue(event.target.value)} />;
const hasError = value.length > 0 && value.length < 3;

return (
<Input
size="m"
state={hasError ? 'critical' : 'default'}
label="Project name"
helperText={hasError ? 'Enter at least three characters' : 'Shown throughout the workspace'}
isRequired
placeholder="Type something"
value={value}
onChange={(event) => setValue(event.target.value)}
onClear={() => setValue('')}
clearLabel="Clear project name"
/>
);
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import PropsTable from '../../../../components/api/props-table.astro';
import { TextAreaExample } from '../../../../components/ui-examples/text-area';

A `TextArea` is a multi-line text field for longer, free-form input. It
auto-resizes to fit its content and supports row limits, an error state, and the
standard input sizes.
auto-resizes to fit its content and supports row limits, letter sizes, icon
slots, a clear affordance, and explicit validation and read-only states.

<TextAreaExample client:visible />

Expand All @@ -21,7 +21,17 @@ import { useState } from 'react';

function Example() {
const [value, setValue] = useState('');
return <TextArea placeholder="Multi-line input" value={value} onChange={(event) => setValue(event.target.value)} />;
return (
<TextArea
size="m"
state={value ? 'success' : 'default'}
label="Description"
helperText="Explain what this workflow does"
placeholder="Multi-line input"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
);
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function TextAreaControl(props: TextAreaControlProps) {
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
size="medium"
size="m"
/>
</ControlWrapper>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function TextControl(props: TextControlProps) {
value={inputValue}
onChange={onChange}
onBlur={onBlur}
error={hasErrors}
state={hasErrors ? 'critical' : 'default'}
disabled={isDisabled}
placeholder={placeholder}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,7 @@ export function DynamicTypedInput({
className={className}
value={value}
onChange={(event) => onChange(event.target.value as string)}
// Adornment here doesn't make sense since we show variable picker above
// endAdornment={endAdornment}
error={isError || isInvalidNumberValue}
state={isError || isInvalidNumberValue ? 'critical' : 'default'}
placeholder={
placeholder ??
t(baseType === 'number' ? 'variables.placeholderTypeNumber' : 'variables.placeholderTypeString')
Expand All @@ -104,13 +102,13 @@ export function DynamicTypedInput({
return (
<div className={styles['container--select']}>
<Select
error={isError}
className={className}
value={value}
items={itemsForBoolean}
onChange={(_event, value) => onChange(value as string)}
placeholder={placeholder}
disabled={disabled}
error={isError}
/>
{endAdornment && <span className={styles['adornment--select']}>{endAdornment}</span>}
</div>
Expand All @@ -121,6 +119,7 @@ export function DynamicTypedInput({
return (
<div className={styles['date-with-reset-container']}>
<DatePicker
error={isError}
key={value}
className={clsx(styles['date-picker'], className)}
value={getDateIfValid(value)}
Expand All @@ -131,7 +130,6 @@ export function DynamicTypedInput({
}}
valueFormat={'dd-MM-yyyy'}
placeholder={placeholder || 'DD-MM-YYYY'}
error={isError}
disabled={disabled}
/>
{endAdornment && <span className={styles['adornment--date']}>{endAdornment}</span>}
Expand All @@ -145,6 +143,7 @@ export function DynamicTypedInput({
return (
<div className={styles['row']}>
<DatePicker
error={isError}
key={value}
className={clsx(styles['date-picker'], className)}
value={date}
Expand All @@ -159,12 +158,12 @@ export function DynamicTypedInput({
// valueFormat="DD-MM-YYYY HH:mm"
// placeholder="DD-MM-YYYY HH:mm"
disabled={disabled}
error={isError}
/>
<Input
className={className}
value={time}
placeholder="HH:mm"
suffixIcon={endAdornment}
onChange={(event) => {
const value = (event.target.value as string).slice(0, 5);
if (value.length < 5) {
Expand All @@ -190,8 +189,7 @@ export function DynamicTypedInput({
}
}}
disabled={disabled || !date}
error={isError}
endAdornment={endAdornment}
state={isError ? 'critical' : 'default'}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
transition: all var(--ax-public-transition);

&.control--error {
background-color: var(--ax-public-input-root-background-color-error);
border-color: var(--ax-public-input-root-border-color-error);
color: var(--ax-public-input-root-color-error);
background-color: var(--ax-public-input-root-background-color-critical);
border-color: var(--ax-public-input-root-border-color-critical);
color: var(--ax-public-input-root-color-critical);

.mention {
border: 1px solid color-mix(in srgb, var(--ax-public-input-root-border-color-error), transparent 90%);
background-color: color-mix(in srgb, var(--ax-public-input-root-border-color-error), transparent 90%);
border: 1px solid color-mix(in srgb, var(--ax-public-input-root-border-color-critical), transparent 90%);
background-color: color-mix(in srgb, var(--ax-public-input-root-border-color-critical), transparent 90%);
}
}
}
Expand Down
Loading