Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion bestax-mcp/data/components/DateInput.json
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@
"name": "isLoading",
"type": "boolean",
"default": "false",
"description": "Shows a loading spinner on the `Control` it renders, and hides the launcher (`triggerIcon`) while it does. Inside your own `Control` it renders none, so this draws nothing; set `isLoading` on that `Control`. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded).",
"description": "Shows a loading spinner on the `Control` it renders, and hides the launcher (`triggerIcon`) while it does. Inside your own `Control` it renders none, so this draws nothing and warns in development; set `isLoading` on that `Control`. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded).",
"required": false,
"inherited": false,
"deprecated": false
Expand Down
2 changes: 1 addition & 1 deletion bestax-mcp/data/components/DateTimeInput.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@
"name": "isLoading",
"type": "boolean",
"default": "false",
"description": "Shows a loading spinner on the `Control` it renders, and hides the launcher (`triggerIcon`) while it does. Inside your own `Control` it renders none, so this draws nothing; set `isLoading` on that `Control`. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded).",
"description": "Shows a loading spinner on the `Control` it renders, and hides the launcher (`triggerIcon`) while it does. Inside your own `Control` it renders none, so this draws nothing and warns in development; set `isLoading` on that `Control`. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded).",
"required": false,
"inherited": false,
"deprecated": false
Expand Down
2 changes: 1 addition & 1 deletion bestax-mcp/data/components/TimeInput.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@
"name": "isLoading",
"type": "boolean",
"default": "false",
"description": "Shows a loading spinner on the `Control` it renders, and hides the launcher (`triggerIcon`) while it does. Inside your own `Control` it renders none, so this draws nothing; set `isLoading` on that `Control`. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded).",
"description": "Shows a loading spinner on the `Control` it renders, and hides the launcher (`triggerIcon`) while it does. Inside your own `Control` it renders none, so this draws nothing and warns in development; set `isLoading` on that `Control`. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded).",
"required": false,
"inherited": false,
"deprecated": false
Expand Down
2 changes: 1 addition & 1 deletion bestax-mcp/data/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"promptName": "form",
"dir": "bestax-form",
"references": [
{ "id": "api", "file": "references/api.md", "bytes": 9754 },
{ "id": "api", "file": "references/api.md", "bytes": 10104 },
{ "id": "patterns", "file": "references/patterns.md", "bytes": 6943 }
],
"examples": []
Expand Down
5 changes: 5 additions & 0 deletions bulma-ui/src/form/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ Conventions:
cannot fix them: Autocomplete's own markup carries a `.control`, and Numberinput's root is
itself a `.field`, so either one still nests inside a bare `Control` with no Field of its
own. Bringing them in means changing that markup first.
Decide the Control with `rendersOwnControl` the same way: pass it every prop you hand your
own `Control`, as the caller gave it rather than after a default, so that inside a caller's
`Control`, where those props do nothing, it can warn about the ones set (#921). A picker
passes its `inline` too, since inline it renders no Control in either place. A new
Control-level prop joins `ControlLevelProps`, and `bare-control.test.tsx` fails until it does.
- `*Base.tsx` files (`InputBase`, `SelectBase`, `DateInputBase`, `TimeInputBase`, …) are the
raw controls without the Field/Control wrapping — deliberately exported from `src/index.ts`
as escape hatches, so they are public API too.
Expand Down
3 changes: 2 additions & 1 deletion bulma-ui/src/form/Checkboxes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useInsideField,
useInsideControl,
rendersOwnField,
rendersOwnControl,
CheckboxesProvider,
CheckboxesGroupContextValue,
} from './FormContext';
Expand Down Expand Up @@ -153,7 +154,7 @@ const CheckboxesComponent: React.FC<CheckboxesProps> = ({

let content = checkboxesElement;

if (!insideControl) {
if (rendersOwnControl('Checkboxes', { insideControl })) {
content = <Control>{content}</Control>;
}

Expand Down
46 changes: 34 additions & 12 deletions bulma-ui/src/form/DateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ import {
useInsideField,
useInsideControl,
rendersOwnField,
rendersOwnControl,
} from './FormContext';
import { useAutoLabelId } from './useAutoLabelId';

/**
* Props for the DateInput convenience wrapper. Extends `DateInputBaseProps`
* with Field-level (label, horizontal) and Control-level (icons, loading) props.
* Inside an existing `Control` it renders no `Control` of its own, so set the
* Control-level props on that `Control` instead. Inside an outer `Field`, or a
* `Control` with no `Field` around it, it renders no `Field` of its own either.
* Inside an existing `Control` it renders no `Control` of its own, so its
* Control-level props do nothing there and warn in development; set them on
* that `Control` instead. In `inline` mode it renders no `Control` anywhere,
* so they do nothing inside a `Control` or out, and warn in development
* there too. Inside an outer `Field`, or a `Control` with no `Field` around
* it, it renders no `Field` of its own either.
* The exception is `label`, `message`, `horizontal` or `fieldClassName` in that
* bare `Control`: it keeps a `Field` for them, nested in the `.control`, and
* warns in development. Wrap the `Control` in a `Field` instead, and set the
Expand Down Expand Up @@ -60,7 +64,8 @@ export interface DateInputProps extends DateInputBaseProps {
/**
* Shows a loading spinner on the `Control` it renders, and hides the
* launcher (`triggerIcon`) while it does. Inside your own `Control` it
* renders none, so this draws nothing; set `isLoading` on that `Control`.
* renders none, so this draws nothing and warns in development; set
* `isLoading` on that `Control`.
* Under `prefers-reduced-motion: reduce` the spinner stops and stays
* drawn (with bestax's CSS loaded).
*/
Expand Down Expand Up @@ -105,8 +110,8 @@ export interface DateInputProps extends DateInputBaseProps {
* />
*/
export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
(
{
(props, ref) => {
const {
label,
labelSize,
labelProps,
Expand All @@ -127,9 +132,7 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
fieldClassName,
controlClassName,
...baseProps
},
ref
) => {
} = props;
const insideField = useInsideField();
const insideControl = useInsideControl();
const ownField = rendersOwnField('DateInput', {
Expand All @@ -140,6 +143,27 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
horizontal,
fieldClassName,
});
// Inline mode renders a bare calendar with no input, so the Control's
// icon-left container has nothing to anchor to, and it renders no
// Control inside one or out. The left icon is checked as the caller
// passed it: the default glyph is this component's own choice, not a
// prop the caller set.
const ownControl = rendersOwnControl('DateInput', {
insideControl,
inline: baseProps.inline,
isLoading,
iconLeft,
iconLeftName: props.iconLeftName,
Comment thread
allxsmith marked this conversation as resolved.
Resolved
iconLeftSize,
iconRight,
iconRightName,
iconRightSize,
hasIconsLeft,
hasIconsRight,
isExpanded,
controlSize,
controlClassName,
});
const { controlId, fieldLabelProps } = useAutoLabelId({
label,
id: baseProps.id,
Expand All @@ -157,9 +181,7 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
<DateInputBase ref={ref} id={controlId} {...baseProps} />
);

// Inline mode renders a bare calendar with no input, so the Control's
// icon-left container has nothing to anchor to. Skip the Control wrap.
if (!insideControl && !baseProps.inline) {
if (ownControl) {
content = (
<Control
iconLeft={iconLeft}
Expand Down
46 changes: 34 additions & 12 deletions bulma-ui/src/form/DateTimeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ import {
useInsideField,
useInsideControl,
rendersOwnField,
rendersOwnControl,
} from './FormContext';
import { useAutoLabelId } from './useAutoLabelId';

/**
* Props for the DateTimeInput convenience wrapper. Extends
* `DateTimeInputBaseProps` with Field-level and Control-level props.
* Inside an existing `Control` it renders no `Control` of its own, so set the
* Control-level props on that `Control` instead. Inside an outer `Field`, or a
* `Control` with no `Field` around it, it renders no `Field` of its own either.
* Inside an existing `Control` it renders no `Control` of its own, so its
* Control-level props do nothing there and warn in development; set them on
* that `Control` instead. In `inline` mode it renders no `Control` anywhere,
* so they do nothing inside a `Control` or out, and warn in development
* there too. Inside an outer `Field`, or a `Control` with no `Field` around
* it, it renders no `Field` of its own either.
* The exception is `label`, `message`, `horizontal` or `fieldClassName` in that
* bare `Control`: it keeps a `Field` for them, nested in the `.control`, and
* warns in development. Wrap the `Control` in a `Field` instead, and set the
Expand Down Expand Up @@ -58,7 +62,8 @@ export interface DateTimeInputProps extends DateTimeInputBaseProps {
/**
* Shows a loading spinner on the `Control` it renders, and hides the
* launcher (`triggerIcon`) while it does. Inside your own `Control` it
* renders none, so this draws nothing; set `isLoading` on that `Control`.
* renders none, so this draws nothing and warns in development; set
* `isLoading` on that `Control`.
* Under `prefers-reduced-motion: reduce` the spinner stops and stays
* drawn (with bestax's CSS loaded).
*/
Expand Down Expand Up @@ -103,8 +108,8 @@ export interface DateTimeInputProps extends DateTimeInputBaseProps {
* />
*/
export const DateTimeInput = forwardRef<HTMLInputElement, DateTimeInputProps>(
(
{
(props, ref) => {
const {
label,
labelSize,
labelProps,
Expand All @@ -125,9 +130,7 @@ export const DateTimeInput = forwardRef<HTMLInputElement, DateTimeInputProps>(
fieldClassName,
controlClassName,
...baseProps
},
ref
) => {
} = props;
const insideField = useInsideField();
const insideControl = useInsideControl();
const ownField = rendersOwnField('DateTimeInput', {
Expand All @@ -138,6 +141,27 @@ export const DateTimeInput = forwardRef<HTMLInputElement, DateTimeInputProps>(
horizontal,
fieldClassName,
});
// Inline mode renders a bare picker with no input, so the Control's
// icon-left container has nothing to anchor to, and it renders no
// Control inside one or out. The left icon is checked as the caller
// passed it: the default glyph is this component's own choice, not a
// prop the caller set.
const ownControl = rendersOwnControl('DateTimeInput', {
insideControl,
inline: baseProps.inline,
isLoading,
iconLeft,
iconLeftName: props.iconLeftName,
iconLeftSize,
iconRight,
iconRightName,
iconRightSize,
hasIconsLeft,
hasIconsRight,
isExpanded,
controlSize,
controlClassName,
});
const { controlId, fieldLabelProps } = useAutoLabelId({
label,
id: baseProps.id,
Expand All @@ -155,9 +179,7 @@ export const DateTimeInput = forwardRef<HTMLInputElement, DateTimeInputProps>(
<DateTimeInputBase ref={ref} id={controlId} {...baseProps} />
);

// Inline mode renders a bare picker with no input, so the Control's
// icon-left container has nothing to anchor to. Skip the Control wrap.
if (!insideControl && !baseProps.inline) {
if (ownControl) {
content = (
<Control
iconLeft={iconLeft}
Expand Down
98 changes: 98 additions & 0 deletions bulma-ui/src/form/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { createContext, useContext } from 'react';
import { warnOnce } from '../helpers/devWarnings';
import type { ControlBaseProps } from './Control';

const FieldContext = createContext(false);
const ControlContext = createContext(false);
Expand Down Expand Up @@ -100,6 +101,103 @@ export const rendersOwnField = (
return true;
};

/** The props a convenience form wrapper hands its own `Control`. Internal. */
export interface ControlLevelProps {
/** Shows the `Control`'s loading spinner. */
isLoading?: boolean;
/** The `Control`'s left icon. */
iconLeft?: ControlBaseProps['iconLeft'];
/** Shortcut for the left icon's name. */
iconLeftName?: string;
/** Shortcut for the left icon's size. */
iconLeftSize?: ControlBaseProps['iconLeftSize'];
/** The `Control`'s right icon. */
iconRight?: ControlBaseProps['iconRight'];
/** Shortcut for the right icon's name. */
iconRightName?: string;
/** Shortcut for the right icon's size. */
iconRightSize?: ControlBaseProps['iconRightSize'];
/** Reserves the `Control`'s left icon space. */
hasIconsLeft?: boolean;
/** Reserves the `Control`'s right icon space. */
hasIconsRight?: boolean;
/** Expands the `Control`. */
isExpanded?: boolean;
/** Set on the `Control` as its `size`. */
controlSize?: ControlBaseProps['size'];
/** Set on the `Control` as its class. */
controlClassName?: string;
}

interface OwnControlOptions extends ControlLevelProps {
/** `useInsideControl()` as the wrapper read it. */
insideControl: boolean;
/** A picker's `inline`, which renders no `Control` inside one or out. */
inline?: boolean;
}

// Each Control-level prop, with what takes its place on the `Control` the
// caller wrapped around the wrapper. `bare-control.test.tsx` holds this to
// what each wrapper hands its own `Control`, so a new Control-level prop
// fails there until it is added here and passed to `rendersOwnControl`.
const ON_CONTROL: Record<keyof ControlLevelProps, string> = {
isLoading: 'isLoading',
iconLeft: 'iconLeft',
iconLeftName: 'iconLeftName',
iconLeftSize: 'iconLeftSize',
iconRight: 'iconRight',
iconRightName: 'iconRightName',
iconRightSize: 'iconRightSize',
hasIconsLeft: 'hasIconsLeft',
hasIconsRight: 'hasIconsRight',
isExpanded: 'isExpanded',
controlSize: 'size (for controlSize)',
controlClassName: 'className (for controlClassName)',
};

/**
* Whether a convenience form wrapper renders a `Control` of its own (#921):
* only outside one. Inside a `Control` it renders none, so the props it
* would have handed its own do nothing there, and it warns in development,
* naming them and saying to set them on that `Control`. An `inline` picker
* renders none in either place, so it drops them inside a `Control` or not,
* and its warning says so without pointing at a `Control`. A wrapper passes
* only the props it hands its own `Control`, as the caller gave them, so a
* default the wrapper fills in itself never warns. A prop counts when it is
* truthy, since a falsy one would change nothing on a `Control` either.
* Internal; not part of the public API.
*/
export const rendersOwnControl = (
component: string,
options: OwnControlOptions
): boolean => {
if (!options.inline && !options.insideControl) return true;
const held = (
Object.keys(ON_CONTROL) as Array<keyof ControlLevelProps>
).filter(prop => options[prop]);
if (held.length === 0) return false;
const names = held.join(' ');
const doNothing = held.length > 1 ? 'those props do' : 'that prop does';
if (options.inline) {
warnOnce(
`${component}:Control-props-inline:${held.join('+')}`,
`[bestax-bulma] <${component} inline ${names}> renders no <Control> ` +
`in inline mode, inside a <Control> or not, so ${doNothing} ` +
`nothing. Leave ${held.length > 1 ? 'them' : 'it'} out of an ` +
`inline picker.`
);
} else {
warnOnce(
`${component}:Control-props-in-Control:${held.join('+')}`,
`[bestax-bulma] <${component} ${names}> inside a <Control> renders ` +
`no <Control> of its own, so ${doNothing} nothing there. Set ` +
`${listOf(held.map(prop => ON_CONTROL[prop]))} on that <Control> ` +
`instead.`
);
}
return false;
};

const FieldLabelIdContext = createContext<string | undefined>(undefined);

/**
Expand Down
Loading
Loading