diff --git a/.changeset/calm-triggers-render.md b/.changeset/calm-triggers-render.md new file mode 100644 index 000000000..b74c404c0 --- /dev/null +++ b/.changeset/calm-triggers-render.md @@ -0,0 +1,5 @@ +--- +"@radui/ui": patch +--- + +Fix Select and Combobox trigger callback type inference. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b916cca4..4c79604dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,3 +87,6 @@ jobs: - name: Check public API surface run: npm run check:api-surface + + - name: Check consumer types + run: npm run check:consumer-types diff --git a/docs/app/docs/components/select/content.mdx b/docs/app/docs/components/select/content.mdx index 7ef4f2bdc..f8cac9202 100644 --- a/docs/app/docs/components/select/content.mdx +++ b/docs/app/docs/components/select/content.mdx @@ -1,6 +1,6 @@ import Documentation from '@/components/layout/Documentation/Documentation'; import SelectExample from "./docs/example_1" -import { code, largeListCode, anatomy, ariaReferences, keyboardShortcuts, api_documentation } from "./docs/codeUsage" +import { code, largeListCode, renderValueCode, anatomy, ariaReferences, keyboardShortcuts, api_documentation } from "./docs/codeUsage" + + Before a value is selected, the trigger displays its children as a placeholder. Once selected, it displays the item's label: its `label` prop, its text content, or its value as a fallback. Items hidden inside custom components may display their value until the list mounts. + Use `renderValue` to customize the selected display. It receives the value and resolved label, and is called only when a value is selected. Returning `null` or `undefined` falls back to the default display. + + {renderValueCode.javascript.code} + + + Use a controlled value and keep each option's `value` and `label` stable when a Select has hundreds of options. This incremental pattern keeps keyboard navigation intact by mounting more rows before focus reaches the boundary. @@ -21,7 +29,7 @@ import { code, largeListCode, anatomy, ariaReferences, keyboardShortcuts, api_do diff --git a/docs/app/docs/components/select/docs/codeUsage.js b/docs/app/docs/components/select/docs/codeUsage.js index 87f5576a6..d5a7e955f 100644 --- a/docs/app/docs/components/select/docs/codeUsage.js +++ b/docs/app/docs/components/select/docs/codeUsage.js @@ -10,10 +10,12 @@ import { DOCS_KEYBOARD_SHORTCUTS } from '../../shared/keyboardShortcuts'; import root_api from './component_api/root.tsx'; +import trigger_api from './component_api/trigger.tsx'; import item_api from './component_api/item.tsx'; const example_1_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/select/docs/example_1.tsx'); const largeList_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/select/docs/large-list.tsx'); +const renderValue_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/select/docs/render-value.tsx'); const scss_SourceCode = await getSourceCodeFromPath('styles/themes/components/select.scss'); const anatomy_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/select/docs/anatomy.tsx'); @@ -26,10 +28,15 @@ export const largeListCode = { javascript: { code: largeList_SourceCode } }; +export const renderValueCode = { + javascript: { code: renderValue_SourceCode } +}; + export const anatomy = { code: anatomy_SourceCode }; export const api_documentation = { root: root_api, + trigger: trigger_api, item: item_api }; diff --git a/docs/app/docs/components/select/docs/component_api/item.tsx b/docs/app/docs/components/select/docs/component_api/item.tsx index d0049b250..3db57efe4 100644 --- a/docs/app/docs/components/select/docs/component_api/item.tsx +++ b/docs/app/docs/components/select/docs/component_api/item.tsx @@ -8,6 +8,7 @@ const data = { ], data: [ { prop: { name: "value*", info_tooltips: "The value of this option." }, type: "string", default: "--" }, + { prop: { name: "label", info_tooltips: "Text used for the selected display, search, and typeahead." }, type: "string", default: "Item text, then value" }, { prop: { name: "disabled", info_tooltips: "Disables this option." }, type: "boolean", default: "false" }, { prop: { name: "className", info_tooltips: "Additional CSS classes." }, type: "string", default: '""' } ] diff --git a/docs/app/docs/components/select/docs/component_api/trigger.tsx b/docs/app/docs/components/select/docs/component_api/trigger.tsx new file mode 100644 index 000000000..4b595dfc0 --- /dev/null +++ b/docs/app/docs/components/select/docs/component_api/trigger.tsx @@ -0,0 +1,15 @@ +const data = { + name: "Trigger", + description: "Opens the select and displays the selected value.", + columns: [ + { name: "Prop", id: "prop" }, + { name: "Type", id: "type" }, + { name: "Default", id: "default" } + ], + data: [ + { prop: { name: "children", info_tooltips: "Placeholder shown when no value is selected." }, type: "ReactNode", default: "--" }, + { prop: { name: "renderValue", info_tooltips: "Renders the selected value and resolved label. Return null or undefined to use the default display." }, type: "(value: string, label: string) => ReactNode", default: "--" } + ] +} + +export default data diff --git a/docs/app/docs/components/select/docs/render-value.tsx b/docs/app/docs/components/select/docs/render-value.tsx new file mode 100644 index 000000000..3c40cf556 --- /dev/null +++ b/docs/app/docs/components/select/docs/render-value.tsx @@ -0,0 +1,19 @@ +"use client" + +import Select from "@radui/ui/Select" + +export default function CustomSelectValue() { + return ( + + value === "auto" ? "whip-l" : value}> + Choose a move + + + + Auto (whip-l) + Manual + + + + ) +} diff --git a/package.json b/package.json index 3f165c501..655a5a3d3 100644 --- a/package.json +++ b/package.json @@ -401,6 +401,7 @@ "check:clarity": "node scripts/clarity-audit.cjs", "check:theme-coverage": "node scripts/theme-coverage.cjs", "check:api-surface": "node scripts/check-public-api-surface.mjs --check", + "check:consumer-types": "tsc -p scripts/type-tests/tsconfig.json", "validate:changesets": "node scripts/validate-changesets.mjs", "validate:publish": "node scripts/validate-publish-package.cjs", "build-components:esbuild": "npm run compile-components:esbuild && npm run process-components", diff --git a/scripts/type-tests/select-trigger.tsx b/scripts/type-tests/select-trigger.tsx new file mode 100644 index 000000000..442e293fb --- /dev/null +++ b/scripts/type-tests/select-trigger.tsx @@ -0,0 +1,21 @@ +import Select from '@radui/ui/Select'; +import Combobox from '@radui/ui/Combobox'; +import type { ComponentProps } from 'react'; + +const selectProps: ComponentProps = { 'data-testid': 'select', customProp: 'select' }; +const comboboxProps: ComponentProps = { 'data-custom-state': 'ready', customProp: 'combobox' }; +; +; + + { + const display: string = `${value.toUpperCase()}: ${label.toUpperCase()}`; + // @ts-expect-error Selected values are strings, not numbers. + value.toFixed(2); + return display; +}} />; + + { + // @ts-expect-error The label is a string, not a number. + label.toFixed(2); + return `${value.toUpperCase()}: ${label.toUpperCase()}`; +}} />; diff --git a/scripts/type-tests/tsconfig.json b/scripts/type-tests/tsconfig.json new file mode 100644 index 000000000..a3e57a6c6 --- /dev/null +++ b/scripts/type-tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": false, + "types": ["react"] + }, + "include": ["./*.tsx"] +} diff --git a/src/components/ui/Combobox/fragments/ComboboxTrigger.tsx b/src/components/ui/Combobox/fragments/ComboboxTrigger.tsx index a02a5bd4d..9d928c398 100644 --- a/src/components/ui/Combobox/fragments/ComboboxTrigger.tsx +++ b/src/components/ui/Combobox/fragments/ComboboxTrigger.tsx @@ -9,6 +9,7 @@ type ComboboxTriggerElement = React.ElementRef export type ComboboxTriggerProps = React.ComponentPropsWithoutRef & { customRootClass?: string; placeholder?: boolean; + renderValue?: (value: string, label: string) => React.ReactNode; }; const ComboboxTrigger = React.forwardRef(({ customRootClass, children, disabled, placeholder, className, ...props }, forwardedRef) => { @@ -27,7 +28,7 @@ const ComboboxTrigger = React.forwardRef ); -}); +}) as React.ForwardRefExoticComponent>; ComboboxTrigger.displayName = 'ComboboxTrigger'; diff --git a/src/components/ui/Select/fragments/SelectTrigger.tsx b/src/components/ui/Select/fragments/SelectTrigger.tsx index 37bd6a4bf..8c3f683cb 100644 --- a/src/components/ui/Select/fragments/SelectTrigger.tsx +++ b/src/components/ui/Select/fragments/SelectTrigger.tsx @@ -9,6 +9,7 @@ type SelectTriggerElement = React.ElementRef; export type SelectTriggerProps = React.ComponentPropsWithoutRef & { customRootClass?: string; placeholder?: boolean; + renderValue?: (value: string, label: string) => React.ReactNode; }; const SelectTrigger = React.forwardRef(({ customRootClass, children, className, disabled, placeholder, ...props }, forwardedRef) => { @@ -29,7 +30,7 @@ const SelectTrigger = React.forwardRef ); -}); +}) as React.ForwardRefExoticComponent>; SelectTrigger.displayName = 'SelectTrigger';