diff --git a/bestax-mcp/data/bulma-classes.json b/bestax-mcp/data/bulma-classes.json index d1e3f7faf..4da485dfb 100644 --- a/bestax-mcp/data/bulma-classes.json +++ b/bestax-mcp/data/bulma-classes.json @@ -1071,6 +1071,166 @@ "noHelpers": false, "ownClassOnly": false }, + "field": { + "status": "mapped", + "target": "Field", + "tag": "div", + "as": null, + "sizeDrivesTag": false, + "textColor": "textColor", + "bgColor": "bgColor", + "part": false, + "why": null, + "modifiers": { + "is-horizontal": { "writes": [{ "prop": "horizontal" }] }, + "is-grouped": { "writes": [{ "prop": "grouped" }] }, + "has-addons": { "writes": [{ "prop": "hasAddons" }] }, + "is-narrow": { "writes": [{ "prop": "narrow" }] } + }, + "omits": {}, + "wrapsChildren": { + "in": "field-body", + "unless": ["Field.Label", "Field.Body"], + "whenEmpty": true, + "when": "is-horizontal" + }, + "noHelpers": false, + "ownClassOnly": false + }, + "field-label": { + "status": "mapped", + "target": "Field.Label", + "tag": "div", + "as": null, + "sizeDrivesTag": false, + "textColor": null, + "bgColor": null, + "part": false, + "why": null, + "modifiers": { + "is-small": { "writes": [{ "prop": "size", "value": "small" }] }, + "is-normal": { "writes": [{ "prop": "size", "value": "normal" }] }, + "is-medium": { "writes": [{ "prop": "size", "value": "medium" }] }, + "is-large": { "writes": [{ "prop": "size", "value": "large" }] } + }, + "omits": {}, + "wrapsChildren": null, + "noHelpers": true, + "ownClassOnly": false + }, + "field-body": { + "status": "mapped", + "target": "Field.Body", + "tag": "div", + "as": null, + "sizeDrivesTag": false, + "textColor": null, + "bgColor": null, + "part": false, + "why": null, + "modifiers": {}, + "omits": {}, + "wrapsChildren": null, + "noHelpers": true, + "ownClassOnly": false + }, + "control": { + "status": "mapped", + "target": "Control", + "tag": "div", + "as": ["div", "p"], + "sizeDrivesTag": false, + "textColor": null, + "bgColor": null, + "part": false, + "why": null, + "modifiers": { + "has-icons-left": { "writes": [{ "prop": "hasIconsLeft" }] }, + "has-icons-right": { "writes": [{ "prop": "hasIconsRight" }] }, + "is-loading": { "writes": [{ "prop": "isLoading" }] }, + "is-expanded": { "writes": [{ "prop": "isExpanded" }] }, + "is-small": { "writes": [{ "prop": "size", "value": "small" }] }, + "is-medium": { "writes": [{ "prop": "size", "value": "medium" }] }, + "is-large": { "writes": [{ "prop": "size", "value": "large" }] } + }, + "omits": {}, + "wrapsChildren": null, + "noHelpers": true, + "ownClassOnly": false + }, + "input": { + "status": "mapped", + "target": "InputBase", + "tag": "input", + "as": null, + "sizeDrivesTag": false, + "textColor": null, + "bgColor": "backgroundColor", + "part": false, + "why": null, + "modifiers": { + "is-small": { "writes": [{ "prop": "size", "value": "small" }] }, + "is-medium": { "writes": [{ "prop": "size", "value": "medium" }] }, + "is-large": { "writes": [{ "prop": "size", "value": "large" }] }, + "is-rounded": { "writes": [{ "prop": "isRounded" }] }, + "is-static": { "writes": [{ "prop": "isStatic" }] }, + "is-hovered": { "writes": [{ "prop": "isHovered" }] }, + "is-focused": { "writes": [{ "prop": "isFocused" }] }, + "is-loading": { "writes": [{ "prop": "isLoading" }] } + }, + "omits": { + "is-primary": "`color` renders `has-text-` on the input as well", + "is-link": "`color` renders `has-text-` on the input as well", + "is-info": "`color` renders `has-text-` on the input as well", + "is-success": "`color` renders `has-text-` on the input as well", + "is-warning": "`color` renders `has-text-` on the input as well", + "is-danger": "`color` renders `has-text-` on the input as well", + "is-black": "`color` renders `has-text-` on the input as well", + "is-white": "`color` renders `has-text-` on the input as well", + "is-light": "`color` renders `has-text-` on the input as well", + "is-dark": "`color` renders `has-text-` on the input as well" + }, + "wrapsChildren": null, + "noHelpers": false, + "ownClassOnly": false + }, + "textarea": { + "status": "mapped", + "target": "TextAreaBase", + "tag": "textarea", + "as": null, + "sizeDrivesTag": false, + "textColor": null, + "bgColor": "backgroundColor", + "part": false, + "why": null, + "modifiers": { + "is-small": { "writes": [{ "prop": "size", "value": "small" }] }, + "is-medium": { "writes": [{ "prop": "size", "value": "medium" }] }, + "is-large": { "writes": [{ "prop": "size", "value": "large" }] }, + "is-rounded": { "writes": [{ "prop": "isRounded" }] }, + "is-static": { "writes": [{ "prop": "isStatic" }] }, + "is-hovered": { "writes": [{ "prop": "isHovered" }] }, + "is-focused": { "writes": [{ "prop": "isFocused" }] }, + "is-active": { "writes": [{ "prop": "isActive" }] }, + "has-fixed-size": { "writes": [{ "prop": "hasFixedSize" }] } + }, + "omits": { + "is-primary": "`color` renders `has-text-` on the textarea as well", + "is-link": "`color` renders `has-text-` on the textarea as well", + "is-info": "`color` renders `has-text-` on the textarea as well", + "is-success": "`color` renders `has-text-` on the textarea as well", + "is-warning": "`color` renders `has-text-` on the textarea as well", + "is-danger": "`color` renders `has-text-` on the textarea as well", + "is-black": "`color` renders `has-text-` on the textarea as well", + "is-white": "`color` renders `has-text-` on the textarea as well", + "is-light": "`color` renders `has-text-` on the textarea as well", + "is-dark": "`color` renders `has-text-` on the textarea as well" + }, + "wrapsChildren": null, + "noHelpers": false, + "ownClassOnly": false + }, "footer": { "status": "mapped", "target": "Footer", @@ -1810,102 +1970,6 @@ "noHelpers": false, "ownClassOnly": false }, - "field": { - "status": "todo", - "target": "Field", - "tag": null, - "as": null, - "sizeDrivesTag": false, - "textColor": null, - "bgColor": null, - "part": false, - "why": "bestax form controls render their own `.field` and `.control` wrappers; see the unmappables reference", - "modifiers": {}, - "omits": {}, - "wrapsChildren": null, - "noHelpers": false, - "ownClassOnly": false - }, - "field-label": { - "status": "todo", - "target": null, - "tag": null, - "as": null, - "sizeDrivesTag": false, - "textColor": null, - "bgColor": null, - "part": true, - "why": null, - "modifiers": {}, - "omits": {}, - "wrapsChildren": null, - "noHelpers": false, - "ownClassOnly": false - }, - "field-body": { - "status": "todo", - "target": null, - "tag": null, - "as": null, - "sizeDrivesTag": false, - "textColor": null, - "bgColor": null, - "part": true, - "why": null, - "modifiers": {}, - "omits": {}, - "wrapsChildren": null, - "noHelpers": false, - "ownClassOnly": false - }, - "control": { - "status": "todo", - "target": null, - "tag": null, - "as": null, - "sizeDrivesTag": false, - "textColor": null, - "bgColor": null, - "part": true, - "why": null, - "modifiers": {}, - "omits": {}, - "wrapsChildren": null, - "noHelpers": false, - "ownClassOnly": false - }, - "input": { - "status": "todo", - "target": "Input", - "tag": null, - "as": null, - "sizeDrivesTag": false, - "textColor": null, - "bgColor": null, - "part": false, - "why": "bestax `Input` renders its own `.field` and `.control` wrappers", - "modifiers": {}, - "omits": {}, - "wrapsChildren": null, - "noHelpers": false, - "ownClassOnly": false - }, - "textarea": { - "status": "todo", - "target": "TextArea", - "tag": null, - "as": null, - "sizeDrivesTag": false, - "textColor": null, - "bgColor": null, - "part": false, - "why": "bestax `TextArea` renders its own `.field` and `.control` wrappers", - "modifiers": {}, - "omits": {}, - "wrapsChildren": null, - "noHelpers": false, - "ownClassOnly": false - }, "select": { "status": "todo", "target": "Select", @@ -2917,6 +2981,12 @@ "navbar-item", "navbar-dropdown", "navbar-divider", + "field", + "field-label", + "field-body", + "control", + "input", + "textarea", "buttons", "tags", "button", diff --git a/bestax-mcp/data/skills.json b/bestax-mcp/data/skills.json index f8a1d3b47..a7daacd86 100644 --- a/bestax-mcp/data/skills.json +++ b/bestax-mcp/data/skills.json @@ -103,17 +103,17 @@ { "id": "bulma-classes-component-map", "file": "references/bulma-classes/component-map.md", - "bytes": 9829 + "bytes": 11299 }, { "id": "bulma-classes-prop-map", "file": "references/bulma-classes/prop-map.md", - "bytes": 18331 + "bytes": 21038 }, { "id": "bulma-classes-unmappables", "file": "references/bulma-classes/unmappables.md", - "bytes": 10958 + "bytes": 12302 }, { "id": "css-migration", diff --git a/bestax-mcp/src/__tests__/server.test.ts b/bestax-mcp/src/__tests__/server.test.ts index 34e4bd18b..a81e52b67 100644 --- a/bestax-mcp/src/__tests__/server.test.ts +++ b/bestax-mcp/src/__tests__/server.test.ts @@ -701,6 +701,11 @@ describe('lookup_bulma_classes', () => { '**Component:** `Card`. It renders its children inside a `.card-content` of its own unless one of them is a `Card.Header`,' ); expect(await lookup('card-content')).not.toContain('renders its children'); + // A field wraps its children only when it's horizontal. + expect(await lookup('field is-horizontal', 'div')).toContain( + 'inside a `.field-body` of its own unless one of them is a `Field.Label` or `Field.Body`' + ); + expect(await lookup('field', 'div')).not.toContain('renders its children'); }); it('answers helper classes with no component, and says what that depends on', async () => { diff --git a/bestax-mcp/src/bulma-classes.ts b/bestax-mcp/src/bulma-classes.ts index 02372a29c..1514bbae1 100644 --- a/bestax-mcp/src/bulma-classes.ts +++ b/bestax-mcp/src/bulma-classes.ts @@ -57,6 +57,8 @@ export interface Wraps { unless: string[]; /** It renders that element with no children too. */ whenEmpty?: boolean; + /** Only when the element carries this class (`Field` with `is-horizontal`). */ + when?: string; } export interface HelperRecord { @@ -291,7 +293,12 @@ export function lookupClasses( }; } const target = entry.target; - const wraps = entry.wrapsChildren ?? undefined; + const wrapsChildren = entry.wrapsChildren; + const wraps = + wrapsChildren && + (!wrapsChildren.when || tokens.includes(wrapsChildren.when)) + ? wrapsChildren + : undefined; // Each class as the root's modifier, else as a helper prop, in order; the // first class to set a prop keeps it. diff --git a/bestax-migrate/CLAUDE.md b/bestax-migrate/CLAUDE.md index e0f34d23b..94c97e483 100644 --- a/bestax-migrate/CLAUDE.md +++ b/bestax-migrate/CLAUDE.md @@ -95,7 +95,8 @@ is the one with no library behind it, converting raw Bulma classes on plain JSX. `scripts/gen-mcp-index.mjs` for the MCP server's `lookup_bulma_classes`, so no enums or runtime imports; a change to it wants `pnpm gen:mcp` in the same PR, and bestax-mcp's tests run this planner beside that tool), `plan.ts` (pure: one element's tag, - classes, attributes and what its children became in, a conversion or a TODO out; an entry + classes, attributes, what its children became and the bestax components already around and + inside it in, a conversion or a TODO out; an entry with `wrapsChildren` converts only beside one of its parts, so `transform.ts` plans children before their parents), `rules.ts` (rule ids from a closed vocabulary, since the input is the app's own class strings and telemetry must never carry diff --git a/bestax-migrate/e2e/bulma-classes-truth.test.ts b/bestax-migrate/e2e/bulma-classes-truth.test.ts index d4d99880a..7e46e67e5 100644 --- a/bestax-migrate/e2e/bulma-classes-truth.test.ts +++ b/bestax-migrate/e2e/bulma-classes-truth.test.ts @@ -15,9 +15,10 @@ * table entry converts somewhere, so no row is dead. */ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { FORWARDS_REF, HELPER_TOKENS, @@ -40,8 +41,11 @@ const repoRoot = path.join( '..' ); -/** Elements that must stay empty in HTML. */ -const VOID = new Set(['input', 'hr', 'img', 'br']); +/** + * Elements rendered with no children: the void ones, and ` + + +
+
+ +
+
+
+
+ +
+
+
+

+ +

+
+
+
+
+
+ +
+
+ +
+
+ + ); +} diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/form.output.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/form.output.tsx new file mode 100644 index 000000000..48ce4d0a2 --- /dev/null +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/form.output.tsx @@ -0,0 +1,74 @@ +import { Button, Control, Field, InputBase, TextAreaBase } from "@allxsmith/bestax-bulma"; +export function SignUp() { + // TODO(bestax-migrate): `.icon` stays as markup: bestax `Icon` renders its own `` and adds an `aria-label` + // TODO(bestax-migrate): `.select` stays as markup: bestax `Select` renders the `.select` wrapper and the ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts b/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts index 2e2da662a..76307ab22 100644 --- a/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts +++ b/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts @@ -463,6 +463,70 @@ describe('plan', () => { }); }); + describe('forms', () => { + it('keeps an input color as a class, since color sets the text too', () => { + expect( + plan( + facts('input', 'input is-danger is-small', {}, { hasChildren: false }) + ).conversion + ).toEqual({ + target: 'InputBase', + props: [['size', 'small']], + className: 'is-danger', + drop: [], + numbers: [], + }); + }); + + it('keeps the addon and group variants as classes', () => { + expect( + plan(facts('div', 'field has-addons has-addons-centered')).conversion + ).toEqual({ + target: 'Field', + props: [['hasAddons', true]], + className: 'has-addons-centered', + drop: [], + numbers: [], + }); + }); + + it('keeps a field around a bestax form control as markup', () => { + expect( + plan(facts('div', 'field', {}, { bestaxInside: ['Input'] })).todos + ).toEqual([ + { rule: 'context:Field', message: expect.stringContaining('`Input`') }, + ]); + }); + + it('keeps an input without an id inside a bestax Field as markup', () => { + const input = (attributes: Record) => + plan( + facts('input', 'input', attributes, { + hasChildren: false, + bestaxAround: ['Field'], + }) + ); + expect(input({}).todos.map(todo => todo.rule)).toEqual([ + 'context:InputBase', + ]); + expect(input({ id: 'email' }).conversion?.target).toBe('InputBase'); + }); + + it('keeps an input without an id inside any other component as markup', () => { + const input = (componentsAround: string[]) => + plan( + facts('input', 'input', {}, { hasChildren: false, componentsAround }) + ); + expect(input(['Labelled']).todos).toEqual([ + { + rule: 'context:InputBase', + message: expect.stringContaining('inside ``'), + }, + ]); + expect(input([]).conversion?.target).toBe('InputBase'); + }); + }); + describe('number attributes', () => { it('turns a number spelled the way it renders into a number', () => { expect( diff --git a/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts b/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts index 16a7e2ea2..5d4f7f9f9 100644 --- a/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts +++ b/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts @@ -453,6 +453,61 @@ describe('a component that wraps its children', () => { }); }); +describe('form context', () => { + it('keeps a field or control around a bestax form control as markup', () => { + const { output, rules } = migrate( + 'import { Input } from "@allxsmith/bestax-bulma";\nexport const A = () => (\n
\n
\n \n
\n
\n);\n' + ); + expect(rules).toEqual(['context:Field', 'context:Control']); + expect(output).toContain('
'); + expect(output).toContain('
'); + }); + + it('keeps an input with no id inside a bestax Field as markup', () => { + const inside = (input: string) => + migrate( + `import { Field } from "@allxsmith/bestax-bulma";\nexport const A = () => (\n \n ${input}\n \n);\n` + ); + expect(inside('').rules).toEqual([ + 'context:InputBase', + ]); + expect(inside('').output).toContain( + '' + ); + }); + + it('keeps an input with no id inside any other component as markup', () => { + // Context follows the render tree: `Labelled` renders a labelled Field. + const wrapped = (input: string, wrapper = 'Labelled') => + migrate( + `import { Field } from "@allxsmith/bestax-bulma";\nconst Labelled = ({ children }: { children: React.ReactNode }) => {children};\nexport const A = () => (\n <${wrapper}>\n ${input}\n

Required

\n \n);\n` + ); + expect(wrapped('').rules).toEqual([ + 'context:InputBase', + ]); + expect( + wrapped('').output + ).toContain(''); + expect( + wrapped('', 'React.Fragment') + .rules + ).toEqual([]); + }); + + it('converts a horizontal field once its label and body do', () => { + const { output, rules } = migrate( + 'export const A = () => (\n
\n
\n \n
\n
x
\n
\n);\n' + ); + expect(rules).toEqual([]); + expect(output).toContain(''); + expect( + migrate( + 'export const A = () =>
x
;\n' + ).rules + ).toEqual(['children:Field']); + }); +}); + describe('printing', () => { it('keeps JSX text exactly as written inside return ( … )', () => { const body = diff --git a/bestax-migrate/src/sources/bulma-classes/class-map.ts b/bestax-migrate/src/sources/bulma-classes/class-map.ts index d3bd28fbe..4619d7858 100644 --- a/bestax-migrate/src/sources/bulma-classes/class-map.ts +++ b/bestax-migrate/src/sources/bulma-classes/class-map.ts @@ -78,7 +78,20 @@ export interface RootEntry { readonly in: string; readonly unless: readonly string[]; readonly whenEmpty?: boolean; + /** Only when the element carries this class (`Field` with `is-horizontal`). */ + readonly when?: string; }; + /** + * The target provides context bestax's form controls read to skip wrappers + * of their own, so an element around a bestax component already in the file + * stays markup: that component would render differently. + */ + readonly providesContext?: boolean; + /** + * Inside one of these bestax components already in the file, the target + * takes a generated `id` when the element has none of its own. + */ + readonly adoptsIdFrom?: readonly string[]; /** The target takes no helper props, so every helper class stays a class. */ readonly noHelpers?: boolean; /** @@ -699,6 +712,172 @@ export const ROOTS: Readonly> = { ], passThrough: ['onClick'], }, + // Form markup. `Field` and `Control` tell the bestax form controls inside + // them to skip wrappers of their own, and `InputBase` / `TextAreaBase` are + // the controls without wrappers (a `Field` with no `label` hands them no id). + field: { + ...BASE, + target: 'Field', + tag: 'div', + // `has-addons-centered`, `is-grouped-right` and the like stay classes: + // the prop values that render them render `has-addons` too. + modifiers: flags({ + 'is-horizontal': 'horizontal', + 'is-grouped': 'grouped', + 'has-addons': 'hasAddons', + 'is-narrow': 'narrow', + }), + wrapsChildren: { + in: 'field-body', + unless: ['Field.Label', 'Field.Body'], + whenEmpty: true, + when: 'is-horizontal', + }, + providesContext: true, + ownProps: [ + 'horizontal', + 'grouped', + 'hasAddons', + 'narrow', + 'label', + 'labelSize', + 'labelProps', + 'textColor', + 'color', + 'bgColor', + ], + }, + // Field.Label, Field.Body and Control spread their raw props onto the + // element as well, so a helper prop would reach it as an attribute too. + 'field-label': { + status: 'mapped', + target: 'Field.Label', + tag: 'div', + textColor: null, + bgColor: null, + noHelpers: true, + modifiers: tokens('is-', ['small', 'normal', 'medium', 'large'], 'size'), + ownProps: ['size', 'textColor', 'color', 'bgColor'], + }, + 'field-body': { + status: 'mapped', + target: 'Field.Body', + tag: 'div', + textColor: null, + bgColor: null, + noHelpers: true, + ownProps: ['textColor', 'color', 'bgColor'], + }, + control: { + status: 'mapped', + target: 'Control', + tag: 'div', + as: ['div', 'p'], + textColor: null, + bgColor: null, + noHelpers: true, + modifiers: { + ...flags({ + 'has-icons-left': 'hasIconsLeft', + 'has-icons-right': 'hasIconsRight', + 'is-loading': 'isLoading', + 'is-expanded': 'isExpanded', + }), + ...tokens('is-', ['small', 'medium', 'large'], 'size'), + }, + providesContext: true, + ownProps: [ + 'as', + 'hasIconsLeft', + 'hasIconsRight', + 'isLoading', + 'isExpanded', + 'size', + 'textColor', + 'color', + 'bgColor', + 'iconLeft', + 'iconRight', + 'iconLeftName', + 'iconLeftSize', + 'iconRightName', + 'iconRightSize', + ], + passThrough: ['ref'], + }, + input: { + ...BASE, + target: 'InputBase', + tag: 'input', + // No text color prop, and `backgroundColor` is the one the base reads. + textColor: null, + bgColor: 'backgroundColor', + modifiers: { + ...tokens('is-', ['small', 'medium', 'large'], 'size'), + ...flags({ + 'is-rounded': 'isRounded', + 'is-static': 'isStatic', + 'is-hovered': 'isHovered', + 'is-focused': 'isFocused', + 'is-loading': 'isLoading', + }), + }, + omits: Object.fromEntries( + COMPONENT_COLORS.map(color => [ + `is-${color}`, + '`color` renders `has-text-` on the input as well', + ]) + ), + adoptsIdFrom: ['Field'], + ownProps: [ + 'color', + 'size', + 'isRounded', + 'isStatic', + 'isHovered', + 'isFocused', + 'isLoading', + ], + passThrough: ['disabled', 'readOnly', 'ref'], + }, + textarea: { + ...BASE, + target: 'TextAreaBase', + tag: 'textarea', + textColor: null, + bgColor: 'backgroundColor', + // `isLoading` renders nothing here: Bulma puts `is-loading` on the control. + modifiers: { + ...tokens('is-', ['small', 'medium', 'large'], 'size'), + ...flags({ + 'is-rounded': 'isRounded', + 'is-static': 'isStatic', + 'is-hovered': 'isHovered', + 'is-focused': 'isFocused', + 'is-active': 'isActive', + 'has-fixed-size': 'hasFixedSize', + }), + }, + omits: Object.fromEntries( + COMPONENT_COLORS.map(color => [ + `is-${color}`, + '`color` renders `has-text-` on the textarea as well', + ]) + ), + adoptsIdFrom: ['Field'], + ownProps: [ + 'color', + 'size', + 'isRounded', + 'isStatic', + 'isHovered', + 'isFocused', + 'isLoading', + 'isActive', + 'hasFixedSize', + ], + passThrough: ['disabled', 'readOnly', 'rows', 'ref'], + }, footer: { ...BASE, target: 'Footer', @@ -1066,21 +1245,6 @@ export const ROOTS: Readonly> = { 'Radios', 'bestax `Radios` renders its own `.field` and `.control` wrappers' ), - field: todo( - 'Field', - 'bestax form controls render their own `.field` and `.control` wrappers; see the unmappables reference' - ), - 'field-label': part(), - 'field-body': part(), - control: part(), - input: todo( - 'Input', - 'bestax `Input` renders its own `.field` and `.control` wrappers' - ), - textarea: todo( - 'TextArea', - 'bestax `TextArea` renders its own `.field` and `.control` wrappers' - ), select: todo( 'Select', 'bestax `Select` renders the `.select` wrapper and the `` only | +| `.textarea` | `TextAreaBase` | `