feat(ai): add_select_v0 — dropdown display element tool (45th)
Dropdown/picker closed-state display. Same label-above-input shape as add_form_field_v0 with: - always-present trailing chevron-down icon - when `value` is set: black value text + chevron - when absent: placeholder text styled gray (#94A3B8) + chevron - justifyContent=space_between pushes the chevron to the right edge Explicitly NOT modeled: open-menu state (dropdown list). An open dropdown needs absolute positioning + scrim + per-option states that belong in a different builder; compose via batch_design for now. Wired across all three paths + elements.md keyword map + examples. Tests: - pen-mcp: 7 new cases (value/placeholder rendering, custom trailing_icon, required suffix, id uniqueness, parent_id rollback) - Parametric coverage: auto-extended (+1 case each in 9 files). Total delta: 2955 → 2979 passing. Closes #48; covers part of #50 (select entry).
This commit is contained in:
parent
a08866cdda
commit
912ed1cd5a
|
|
@ -35,6 +35,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -133,6 +134,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
|
|||
add_form_field_v0: (a) => buildFormField(a as Parameters<typeof buildFormField>[0]),
|
||||
add_textarea_v0: (a) => buildTextarea(a as Parameters<typeof buildTextarea>[0]),
|
||||
add_skeleton_v0: (a) => buildSkeleton(a as Parameters<typeof buildSkeleton>[0]),
|
||||
add_select_v0: (a) => buildSelect(a as Parameters<typeof buildSelect>[0]),
|
||||
add_nav_chip_row_v0: (a) => buildNavChipRow(a as Parameters<typeof buildNavChipRow>[0]),
|
||||
add_activity_ring_v0: (a) => buildActivityRing(a as Parameters<typeof buildActivityRing>[0]),
|
||||
add_rating_stars_v0: (a) => buildRatingStars(a as Parameters<typeof buildRatingStars>[0]),
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -174,6 +175,7 @@ const CASES: BuilderCase[] = [
|
|||
{ name: 'form-field', build: () => buildFormField({ label: 'Email' }) },
|
||||
{ name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) },
|
||||
{ name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) },
|
||||
{ name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) },
|
||||
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
|
||||
{
|
||||
name: 'empty-state',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -168,6 +169,7 @@ const CASES: BuilderCase[] = [
|
|||
{ name: 'form-field', build: () => buildFormField({ label: 'Email' }) },
|
||||
{ name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) },
|
||||
{ name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) },
|
||||
{ name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) },
|
||||
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
|
||||
{
|
||||
name: 'empty-state',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -191,6 +192,7 @@ const CASES: BuilderCase[] = [
|
|||
{ name: 'form-field', build: () => buildFormField({ label: 'Email' }) },
|
||||
{ name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) },
|
||||
{ name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) },
|
||||
{ name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) },
|
||||
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
|
||||
{
|
||||
name: 'empty-state',
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -248,6 +249,11 @@ const CASES: BuilderCase[] = [
|
|||
args: { rows: 3 },
|
||||
build: (a) => buildSkeleton(a as unknown as Parameters<typeof buildSkeleton>[0]),
|
||||
},
|
||||
{
|
||||
toolName: 'add_select_v0',
|
||||
args: { label: 'Country', value: 'US' },
|
||||
build: (a) => buildSelect(a as unknown as Parameters<typeof buildSelect>[0]),
|
||||
},
|
||||
{
|
||||
toolName: 'add_form_field_v0',
|
||||
args: { label: 'Email' },
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -98,6 +99,7 @@ import {
|
|||
type SearchBarParams,
|
||||
type SectionHeaderParams,
|
||||
type SegmentedControlParams,
|
||||
type SelectParams,
|
||||
type SkeletonParams,
|
||||
type StatGridParams,
|
||||
type StepperParams,
|
||||
|
|
@ -229,6 +231,7 @@ export const ELEMENT_SHIMS: Record<string, ElementShim> = {
|
|||
add_form_field_v0: wrap<FormFieldParams>(buildFormField),
|
||||
add_textarea_v0: wrap<TextareaParams>(buildTextarea),
|
||||
add_skeleton_v0: wrap<SkeletonParams>(buildSkeleton),
|
||||
add_select_v0: wrap<SelectParams>(buildSelect),
|
||||
add_nav_chip_row_v0: wrap<NavChipRowParams>(buildNavChipRow),
|
||||
add_activity_ring_v0: wrap<ActivityRingParams>(buildActivityRing),
|
||||
add_rating_stars_v0: wrap<RatingStarsParams>(buildRatingStars),
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ Forms:
|
|||
18. Search bar (height=44, cornerRadius=22, leading search icon) → `add_search_bar_v0`
|
||||
19. Form field (label + 48px input with optional affordance icons) → `add_form_field_v0`
|
||||
19b. Multi-line textarea (label + N-row input for notes / bio / feedback) → `add_textarea_v0`
|
||||
19c. Dropdown select closed-state (label + 48px input w/ value text + chevron-down) → `add_select_v0`
|
||||
|
||||
Controls (toggle / choice / tabs):
|
||||
|
||||
|
|
@ -154,6 +155,7 @@ PREFER an element tool when the spec says any of:
|
|||
- "search bar", "search input", "filter search", "搜索栏" → `add_search_bar_v0`
|
||||
- "form field", "email input", "password field", "labeled input", "required field" → `add_form_field_v0`
|
||||
- "textarea", "multi-line input", "notes field", "description box", "bio input", "feedback box", "多行输入", "备注" → `add_textarea_v0`
|
||||
- "dropdown", "select", "picker", "combo box", "下拉选择", "选择器" → `add_select_v0`
|
||||
- "skeleton", "loading placeholder", "shimmer", "loading state", "placeholder lines", "骨架屏", "加载中占位" → `add_skeleton_v0`
|
||||
- "toggle", "switch", "on/off", "开关" → `add_switch_v0`
|
||||
- "checkbox", "agreement", "select option", "复选框" → `add_checkbox_v0`
|
||||
|
|
@ -284,6 +286,8 @@ add_textarea_v0({ label: "Bio", placeholder: "Tell us about yourself", rows: 5 }
|
|||
add_textarea_v0({ label: "Feedback", rows: 4, required: true })
|
||||
add_skeleton_v0({}) // default 3 rows, last short
|
||||
add_skeleton_v0({ rows: 5, row_height: 20, row_gap: 8 })
|
||||
add_select_v0({ label: "Country", value: "United States" })
|
||||
add_select_v0({ label: "Currency", placeholder: "Choose currency", required: true })
|
||||
|
||||
add_switch_v0({}) // off (default)
|
||||
add_switch_v0({ active: true }) // on — iOS green
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -179,6 +180,11 @@ const CASES: LayoutCase[] = [
|
|||
{ name: 'buildFormField', tree: () => buildFormField({ label: 'Email' }), wrap: true },
|
||||
{ name: 'buildTextarea', tree: () => buildTextarea({ label: 'Bio', rows: 5 }), wrap: true },
|
||||
{ name: 'buildSkeleton', tree: () => buildSkeleton({ rows: 3 }), wrap: true },
|
||||
{
|
||||
name: 'buildSelect',
|
||||
tree: () => buildSelect({ label: 'Country', value: 'US' }),
|
||||
wrap: true,
|
||||
},
|
||||
{ name: 'buildChartBars', tree: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
|
||||
|
||||
// Composites
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -170,6 +171,7 @@ const CASES: BuilderCase[] = [
|
|||
{ name: 'form-field', build: () => buildFormField({ label: 'Email' }) },
|
||||
{ name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) },
|
||||
{ name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) },
|
||||
{ name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) },
|
||||
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
|
||||
{
|
||||
name: 'empty-state',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -150,6 +151,7 @@ const CASES: PerfCase[] = [
|
|||
{ name: 'form-field', build: () => buildFormField({ label: 'Email' }) },
|
||||
{ name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) },
|
||||
{ name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) },
|
||||
{ name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) },
|
||||
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5, 8] }) },
|
||||
{
|
||||
name: 'empty-state',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
buildSearchBar,
|
||||
buildSectionHeader,
|
||||
buildSegmentedControl,
|
||||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStepper,
|
||||
|
|
@ -174,6 +175,7 @@ const CASES: BuilderCase[] = [
|
|||
{ name: 'form-field', build: () => buildFormField({ label: 'Email' }) },
|
||||
{ name: 'textarea', build: () => buildTextarea({ label: 'Bio', rows: 5 }) },
|
||||
{ name: 'skeleton', build: () => buildSkeleton({ rows: 3 }) },
|
||||
{ name: 'select', build: () => buildSelect({ label: 'Country', value: 'US' }) },
|
||||
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
|
||||
{
|
||||
name: 'empty-state',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export { buildStepper, type StepperParams } from './stepper.js';
|
|||
export { buildFormField, type FormFieldParams } from './form-field.js';
|
||||
export { buildTextarea, type TextareaParams } from './textarea.js';
|
||||
export { buildSkeleton, type SkeletonParams } from './skeleton.js';
|
||||
export { buildSelect, type SelectParams } from './select.js';
|
||||
export { buildNavChipRow, type NavChipRowItem, type NavChipRowParams } from './nav-chip-row.js';
|
||||
export { buildActivityRing, type ActivityRingParams } from './activity-ring.js';
|
||||
export { buildRatingStars, type RatingStarsParams } from './rating-stars.js';
|
||||
|
|
|
|||
92
packages/pen-core/src/element-builders/select.ts
Normal file
92
packages/pen-core/src/element-builders/select.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { ElementTree } from './helpers.js';
|
||||
|
||||
export interface SelectParams {
|
||||
label: string;
|
||||
/** Currently selected value. Shows in the input area. Omit to show placeholder. */
|
||||
value?: string;
|
||||
/** Placeholder shown when no value is selected (gray). Default "Select…". */
|
||||
placeholder?: string;
|
||||
/** Trailing affordance icon name. Default "chevron-down" (closed state). */
|
||||
trailing_icon?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown select — CLOSED/display state only. Same label-above-
|
||||
* input pattern as form-field with:
|
||||
* - no leading icon slot (not typical for selects)
|
||||
* - trailing chevron-down icon (always present; override via
|
||||
* trailing_icon if the caller wants a custom affordance)
|
||||
* - when `value` is set: input text renders black (fontWeight=400)
|
||||
* as a normal filled input
|
||||
* - when `value` is absent: input text renders the placeholder
|
||||
* and is styled gray (color #94A3B8) so users see it as a hint
|
||||
*
|
||||
* The OPEN state (menu + options list) is NOT modeled here. An open
|
||||
* dropdown is a different design concern (absolute positioning, scrim
|
||||
* layer, per-option hover/selected) that belongs in its own builder
|
||||
* if/when needed. Users compose an open state via batch_design.
|
||||
*/
|
||||
export function buildSelect(params: SelectParams): ElementTree {
|
||||
const labelText = params.required ? `${params.label} *` : params.label;
|
||||
const placeholder = params.placeholder ?? 'Select…';
|
||||
const trailingIcon = params.trailing_icon ?? 'chevron-down';
|
||||
const displayText = params.value ?? placeholder;
|
||||
const isEmpty = params.value === undefined;
|
||||
|
||||
const textNode: ElementTree = {
|
||||
type: 'text',
|
||||
name: isEmpty ? 'Placeholder' : 'Selected Value',
|
||||
content: displayText,
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
};
|
||||
if (isEmpty) {
|
||||
// Placeholder gray color — same token used by form-field's placeholder
|
||||
textNode.fill = [{ type: 'solid', color: '#94A3B8' }];
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'frame',
|
||||
name: 'Select',
|
||||
role: 'select',
|
||||
width: 'fill_container',
|
||||
height: 'fit_content',
|
||||
layout: 'vertical',
|
||||
gap: 6,
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
role: 'label',
|
||||
content: labelText,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
},
|
||||
{
|
||||
type: 'frame',
|
||||
name: 'Input',
|
||||
role: 'select-input',
|
||||
width: 'fill_container',
|
||||
height: 48,
|
||||
cornerRadius: 8,
|
||||
layout: 'horizontal',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space_between',
|
||||
gap: 8,
|
||||
padding: [12, 16],
|
||||
children: [
|
||||
textNode,
|
||||
{
|
||||
type: 'icon_font',
|
||||
name: 'Trailing Icon',
|
||||
iconFontName: trailingIcon,
|
||||
iconFontFamily: 'lucide',
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -205,6 +205,7 @@ export {
|
|||
buildFormField,
|
||||
buildTextarea,
|
||||
buildSkeleton,
|
||||
buildSelect,
|
||||
buildNavChipRow,
|
||||
buildActivityRing,
|
||||
buildRatingStars,
|
||||
|
|
@ -263,6 +264,7 @@ export {
|
|||
type FormFieldParams,
|
||||
type TextareaParams,
|
||||
type SkeletonParams,
|
||||
type SelectParams,
|
||||
type NavChipRowItem,
|
||||
type NavChipRowParams,
|
||||
type ActivityRingParams,
|
||||
|
|
|
|||
134
packages/pen-mcp/src/__tests__/add-select-v0.test.ts
Normal file
134
packages/pen-mcp/src/__tests__/add-select-v0.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes';
|
||||
import { handleAddSelectV0 } from '../tools/add-select-v0';
|
||||
import { invalidateCache } from '../document-manager';
|
||||
|
||||
const TMP = join(tmpdir(), 'openpencil-add-select-v0');
|
||||
const EMPTY = JSON.stringify({ version: '1.0.0', children: [] });
|
||||
|
||||
async function fresh(name: string): Promise<string> {
|
||||
const fp = join(TMP, name);
|
||||
await writeFile(fp, EMPTY, 'utf-8');
|
||||
return fp;
|
||||
}
|
||||
async function readDoc(fp: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(fp, 'utf-8'));
|
||||
}
|
||||
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
|
||||
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
|
||||
const top = doc['children'] as Record<string, unknown>[] | undefined;
|
||||
const root = (top ?? pages?.[0]?.children)?.[0];
|
||||
if (!root) throw new Error('no root');
|
||||
return root;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP, { recursive: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const f of ['a.op']) {
|
||||
try {
|
||||
const fp = join(TMP, f);
|
||||
invalidateCache(fp);
|
||||
await unlink(fp);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('add_select_v0', () => {
|
||||
it('registered + required label', () => {
|
||||
expect(DESIGN_TOOL_NAMES.has('add_select_v0')).toBe(true);
|
||||
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_select_v0');
|
||||
expect(def?.inputSchema.required).toEqual(['label']);
|
||||
});
|
||||
|
||||
it('with value set: black value text + chevron-down trailing', async () => {
|
||||
const fp = await fresh('a.op');
|
||||
await handleAddSelectV0({ filePath: fp, label: 'Country', value: 'United States' });
|
||||
const sel = getRoot(await readDoc(fp));
|
||||
expect(sel.role).toBe('select');
|
||||
expect(sel.layout).toBe('vertical');
|
||||
const [label, input] = sel.children as Record<string, unknown>[];
|
||||
expect(label.content).toBe('Country');
|
||||
expect(input.role).toBe('select-input');
|
||||
expect(input.justifyContent).toBe('space_between');
|
||||
const [textNode, trailing] = input.children as Record<string, unknown>[];
|
||||
expect(textNode.content).toBe('United States');
|
||||
// NOT gray when value is set
|
||||
expect(textNode.fill).toBeUndefined();
|
||||
expect(trailing.type).toBe('icon_font');
|
||||
expect(trailing.iconFontName).toBe('chevron-down');
|
||||
});
|
||||
|
||||
it('no value: placeholder text with gray fill #94A3B8', async () => {
|
||||
const fp = await fresh('a.op');
|
||||
await handleAddSelectV0({ filePath: fp, label: 'Country' });
|
||||
const sel = getRoot(await readDoc(fp));
|
||||
const input = (sel.children as Record<string, unknown>[])[1];
|
||||
const textNode = (input.children as Record<string, unknown>[])[0];
|
||||
expect(textNode.content).toBe('Select…');
|
||||
const fill = textNode.fill as Array<{ color: string }>;
|
||||
expect(fill[0].color).toBe('#94A3B8');
|
||||
});
|
||||
|
||||
it('custom placeholder overrides default "Select…"', async () => {
|
||||
const fp = await fresh('a.op');
|
||||
await handleAddSelectV0({
|
||||
filePath: fp,
|
||||
label: 'Currency',
|
||||
placeholder: 'Choose currency',
|
||||
});
|
||||
const input = (getRoot(await readDoc(fp)).children as Record<string, unknown>[])[1];
|
||||
const textNode = (input.children as Record<string, unknown>[])[0];
|
||||
expect(textNode.content).toBe('Choose currency');
|
||||
});
|
||||
|
||||
it('custom trailing_icon overrides default chevron-down', async () => {
|
||||
const fp = await fresh('a.op');
|
||||
await handleAddSelectV0({
|
||||
filePath: fp,
|
||||
label: 'Country',
|
||||
value: 'US',
|
||||
trailing_icon: 'chevrons-up-down',
|
||||
});
|
||||
const input = (getRoot(await readDoc(fp)).children as Record<string, unknown>[])[1];
|
||||
const trailing = (input.children as Record<string, unknown>[])[1];
|
||||
expect(trailing.iconFontName).toBe('chevrons-up-down');
|
||||
});
|
||||
|
||||
it('required=true appends " *" to label', async () => {
|
||||
const fp = await fresh('a.op');
|
||||
await handleAddSelectV0({ filePath: fp, label: 'Country', required: true });
|
||||
const label = (getRoot(await readDoc(fp)).children as Record<string, unknown>[])[0];
|
||||
expect(label.content).toBe('Country *');
|
||||
});
|
||||
|
||||
it('every node has a unique id', async () => {
|
||||
const fp = await fresh('a.op');
|
||||
await handleAddSelectV0({ filePath: fp, label: 'X', value: 'Y' });
|
||||
const ids: string[] = [];
|
||||
function walk(n: Record<string, unknown>): void {
|
||||
if (typeof n.id === 'string') ids.push(n.id);
|
||||
if (Array.isArray(n.children))
|
||||
(n.children as Record<string, unknown>[]).forEach(
|
||||
(c) => c && typeof c === 'object' && walk(c),
|
||||
);
|
||||
}
|
||||
walk(getRoot(await readDoc(fp)));
|
||||
// select + label + input + (value text + trailing icon) = 5
|
||||
expect(ids.length).toBe(5);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('throws on bogus parent_id AND leaves file untouched', async () => {
|
||||
const fp = await fresh('a.op');
|
||||
const before = await readFile(fp, 'utf-8');
|
||||
await expect(
|
||||
handleAddSelectV0({ filePath: fp, label: 'X', parent_id: 'nope' }),
|
||||
).rejects.toThrow(/parent_id.*not found/);
|
||||
expect(await readFile(fp, 'utf-8')).toBe(before);
|
||||
});
|
||||
});
|
||||
|
|
@ -638,6 +638,42 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [
|
|||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_select_v0',
|
||||
description:
|
||||
'Dropdown select (display / closed state). Label above input box matching add_form_field_v0 ' +
|
||||
'shape, but the input trailing slot is ALWAYS a chevron-down icon affordance. When `value` ' +
|
||||
'is set: input shows value text (black). When absent: input shows placeholder text (gray ' +
|
||||
'#94A3B8). The OPEN state (menu list) is NOT modeled here — compose via batch_design when ' +
|
||||
'needed. Use for "dropdown", "select", "picker", "下拉选择". schemaVersion 1.0',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
schemaVersion: schemaVersionProp,
|
||||
filePath: filePathProp,
|
||||
label: { type: 'string', description: 'Field label text' },
|
||||
value: {
|
||||
type: 'string',
|
||||
description: 'Currently selected value text. Omit to show placeholder instead.',
|
||||
},
|
||||
placeholder: {
|
||||
type: 'string',
|
||||
description: 'Placeholder when value is absent. Default "Select…".',
|
||||
},
|
||||
trailing_icon: {
|
||||
type: 'string',
|
||||
description: 'Trailing icon. Default "chevron-down" (closed state).',
|
||||
},
|
||||
required: {
|
||||
type: 'boolean',
|
||||
description: 'When true, appends " *" to the label text',
|
||||
},
|
||||
parent_id: parentIdProp,
|
||||
pageId: pageIdProp,
|
||||
},
|
||||
required: ['label'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_skeleton_v0',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { handleAddSearchBarV0 } from '../tools/add-search-bar-v0';
|
|||
import { handleAddFormFieldV0 } from '../tools/add-form-field-v0';
|
||||
import { handleAddTextareaV0 } from '../tools/add-textarea-v0';
|
||||
import { handleAddSkeletonV0 } from '../tools/add-skeleton-v0';
|
||||
import { handleAddSelectV0 } from '../tools/add-select-v0';
|
||||
import { handleAddSwitchV0 } from '../tools/add-switch-v0';
|
||||
import { handleAddCheckboxV0 } from '../tools/add-checkbox-v0';
|
||||
import { handleAddRadioV0 } from '../tools/add-radio-v0';
|
||||
|
|
@ -116,6 +117,8 @@ export async function handleElementToolCall(name: string, a: any): Promise<strin
|
|||
return JSON.stringify(await handleAddTextareaV0(a), null, 2);
|
||||
case 'add_skeleton_v0':
|
||||
return JSON.stringify(await handleAddSkeletonV0(a), null, 2);
|
||||
case 'add_select_v0':
|
||||
return JSON.stringify(await handleAddSelectV0(a), null, 2);
|
||||
case 'add_switch_v0':
|
||||
return JSON.stringify(await handleAddSwitchV0(a), null, 2);
|
||||
case 'add_checkbox_v0':
|
||||
|
|
|
|||
23
packages/pen-mcp/src/tools/add-select-v0.ts
Normal file
23
packages/pen-mcp/src/tools/add-select-v0.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { assignIdsRecursively, buildSelect, type SelectParams } from '@zseven-w/pen-core';
|
||||
import type { handleBatchDesign } from './batch-design';
|
||||
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
|
||||
|
||||
export interface AddSelectV0Params extends SelectParams {
|
||||
parent_id?: string;
|
||||
filePath?: string;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown select (display/closed state). Same label-above-input
|
||||
* shape as form-field, with trailing chevron-down. Tree build
|
||||
* delegated to `buildSelect`.
|
||||
*/
|
||||
export async function handleAddSelectV0(
|
||||
params: AddSelectV0Params,
|
||||
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
|
||||
await ensureParentExists(params);
|
||||
const sel = buildSelect(params);
|
||||
assignIdsRecursively(sel);
|
||||
return insertElementTree({ binding: 'sel', tree: sel, ...params });
|
||||
}
|
||||
Loading…
Reference in a new issue