From a498b0976ea110a1f39d2d4de445405246e0ccf0 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sun, 13 Sep 2026 13:33:45 -0400 Subject: [PATCH 01/31] fix(bulma-ui): let Dropdown.Item's props follow its constrained `as` `DropdownItemProps` pinned every prop to `React.HTMLAttributes` while `as` offered `'a' | 'div' | 'button'`, so `href` and `target` under `as="a"`, and `type` and `form` under `as="button"`, were rejected although all of them have always worked at runtime and the docs page shows the anchor form. This is #641 in the narrower shape a constrained `as` takes; #661 fixed the eight components with an open `React.ElementType`. The item is now generic over its three allowed tags, through a constrained variant of the ref-less polymorphic component type. A union of the three prop shapes would have read as equivalent and let `` through: against a union target an object literal's excess-property check passes if the key exists in any member, so the check has to run after `T` is inferred from `as`. `__typetests__` pins both halves -- each member compiles, and every HTML intrinsic outside the three does not. Refs #663 --- bestax-mcp/data/components/Dropdown.json | 20 +++---- bulma-ui/src/__typetests__/polymorphic.tsx | 32 +++++++++-- bulma-ui/src/components/Dropdown.tsx | 66 +++++++++++++++++----- bulma-ui/src/helpers/polymorphic.ts | 31 ++++++++++ bulma-ui/src/index.ts | 1 + docs/docs/api/components/dropdown.md | 16 +++--- 6 files changed, 129 insertions(+), 37 deletions(-) diff --git a/bestax-mcp/data/components/Dropdown.json b/bestax-mcp/data/components/Dropdown.json index ffd681aa9..1fb3f7501 100644 --- a/bestax-mcp/data/components/Dropdown.json +++ b/bestax-mcp/data/components/Dropdown.json @@ -179,6 +179,15 @@ "summary": "Bulma Dropdown item.", "component": null, "props": [ + { + "name": "as", + "type": "'a' | 'div' | 'button'", + "default": "'a'", + "description": "The element type to render.", + "required": false, + "inherited": false, + "deprecated": false + }, { "name": "active", "type": "boolean", @@ -197,15 +206,6 @@ "inherited": false, "deprecated": false }, - { - "name": "as", - "type": "'a' | 'div' | 'button'", - "default": "'a'", - "description": "The element type to render.", - "required": false, - "inherited": false, - "deprecated": false - }, { "name": "disabled", "type": "boolean", @@ -226,7 +226,7 @@ } ], "extraProps": [], - "catchAll": "All standard HTML attributes and Bulma helper props", + "catchAll": "Remaining props of the element or component selected by as (default ) and Bulma helper props", "types": [] }, { diff --git a/bulma-ui/src/__typetests__/polymorphic.tsx b/bulma-ui/src/__typetests__/polymorphic.tsx index 323b8e5aa..bfb57d064 100644 --- a/bulma-ui/src/__typetests__/polymorphic.tsx +++ b/bulma-ui/src/__typetests__/polymorphic.tsx @@ -4,16 +4,18 @@ * These assert types, not behaviour, so they deliberately live OUTSIDE * `__tests__/`: `bulma-ui/tsconfig.json` excludes that directory and every * `*.test.tsx`, and ts-jest runs transpile-only (the repo sets - * `isolatedModules`), so an `@ts-expect-error` in a test file is checked by - * nothing. Here `tsc --noEmit` reads the file — which means `pnpm typecheck` - * and the React 18/19 matrix both enforce it, and an `@ts-expect-error` that - * stops being an error fails the build as TS2578. + * `isolatedModules`), so jest checks no type an `@ts-expect-error` there + * claims. `tsconfig.test.json` now does (#663), but only `pnpm typecheck` runs + * in the React 18/19 matrix — so a file here is the only one whose directives + * are enforced against BOTH majors' types. An `@ts-expect-error` that stops + * being an error fails the build as TS2578. * * Nothing renders these; jest's testMatch does not reach this directory and * `collectCoverageFrom` excludes it. */ import React from 'react'; import { Avatar } from '../components/Avatar'; +import { Dropdown } from '../components/Dropdown'; import { Menu } from '../components/Menu'; import { Navbar } from '../components/Navbar'; import { Reveal } from '../components/Reveal'; @@ -86,6 +88,14 @@ export const accepted = ( ()} /> + {/* A CONSTRAINED `as` — Bulma's dropdown markup allows three tags and no + more — still derives each tag's own props (#663). Before that, every + one of these was pinned to `HTMLAttributes` and rejected. */} + + + + + @@ -215,6 +225,20 @@ export const rejected = ( {/* @ts-expect-error a div takes no href */} + {/* The constraint is the other half of #663: deriving props from `as` must + not quietly reopen `as` to anything. A union of prop shapes would have + let the first of these through — against a union target an object + literal's excess-property check passes if the key exists in ANY member — + which is why the component is generic over its allowed set instead. */} + {/* @ts-expect-error a div takes no href */} + + {/* @ts-expect-error an anchor takes no `form` */} + + {/* @ts-expect-error a span is not one of the three tags Bulma allows */} + + {/* @ts-expect-error nor is a component, however link-like */} + + {/* Reveal forwards NO ref, deliberately — it observes a node it owns, which for a component `as` is a wrapper div rather than the element named. A ref accepted here would sit at `null` forever. Nothing else pins this, diff --git a/bulma-ui/src/components/Dropdown.tsx b/bulma-ui/src/components/Dropdown.tsx index 4f65c0c0e..39f1b709a 100644 --- a/bulma-ui/src/components/Dropdown.tsx +++ b/bulma-ui/src/components/Dropdown.tsx @@ -8,6 +8,7 @@ import React, { import { classNames, usePrefixedClassNames } from '../helpers/classNames'; import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; import { withSubComponents } from '../helpers/withSubComponents'; +import type { ConstrainedPolymorphicComponentWithoutRef } from '../helpers/polymorphic'; /** * Checks if code is running in a browser environment. @@ -360,24 +361,54 @@ const DropdownComponent = forwardRef( ); /** - * Props for the DropdownItem component. + * The elements a dropdown item may render as. Bulma's dropdown markup names + * these three and no others, so `as` is a closed set rather than an open + * `React.ElementType`. */ -export interface DropdownItemProps - extends - Omit, keyof BulmaClassesProps>, - BulmaClassesProps { +export type DropdownItemElement = 'a' | 'div' | 'button'; + +/** + * The DropdownItem component's own props. + */ +export interface DropdownItemOwnProps extends BulmaClassesProps { /** Whether the item is active. */ active?: boolean; /** Additional CSS classes. */ className?: string; - /** The element type to render. */ - as?: 'a' | 'div' | 'button'; /** Marks the item as disabled; disabled items are skipped during keyboard navigation. Use with `as="button"` for a native disabled control, or pair with `aria-disabled` on a link. */ disabled?: boolean; /** Item content. */ children?: React.ReactNode; } +/** + * Props for the DropdownItem component. Everything the item does not own + * follows `as`: `href`, `target` and `rel` under the default `'a'`, `type` and + * `form` under `'button'`, and the shared HTML attributes under any of them. + * + * Pinning these to `React.HTMLAttributes` instead — which is what + * this type did before — rejected `href` on an anchor and `type` on a button, + * both of which have always worked at runtime. Same defect as #641, in the + * narrower shape a constrained `as` takes. + */ +export type DropdownItemProps = + DropdownItemOwnProps & + Omit< + React.ComponentPropsWithoutRef, + keyof DropdownItemOwnProps | 'as' + > & { + /** The element type to render. */ + as?: T; + }; + +/** + * The shape the implementation destructures. The public contract is the generic + * `DropdownItemProps` above — the body cannot see through `T`. + */ +type DropdownItemImplProps = DropdownItemOwnProps & { + as?: DropdownItemElement; +}; + /** * Bulma Dropdown item. * @@ -385,13 +416,14 @@ export interface DropdownItemProps * @param {DropdownItemProps} props - Props for the DropdownItem component. * @returns {JSX.Element} The rendered dropdown item. */ -export const DropdownItem: React.FC = ({ - children, - active, - className, - as: Component = 'a', - ...props -}) => { +export const DropdownItem = ((itemProps: DropdownItemProps) => { + const { + children, + active, + className, + as: Component = 'a', + ...props + } = itemProps as DropdownItemImplProps; const { bulmaHelperClasses, rest } = useBulmaClasses(props); return ( = ({ {children} ); -}; +}) as ConstrainedPolymorphicComponentWithoutRef< + DropdownItemOwnProps, + DropdownItemElement, + 'a' +>; /** * Bulma Dropdown divider. diff --git a/bulma-ui/src/helpers/polymorphic.ts b/bulma-ui/src/helpers/polymorphic.ts index 8c61384a9..348128f38 100644 --- a/bulma-ui/src/helpers/polymorphic.ts +++ b/bulma-ui/src/helpers/polymorphic.ts @@ -127,3 +127,34 @@ export interface PolymorphicComponentWithoutRef< (props: PolymorphicProps): React.ReactElement | null; displayName?: string; } + +/** + * A component whose props follow `as`, where `as` is a closed set of tags. + * + * `PolymorphicComponentWithoutRef` lets `as` name anything; this narrows it to + * `Allowed`. Bulma pins some elements by its own markup contract — a dropdown + * item is an ``, a `
` or a `