Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';

export type AccordionHeaderProps = ComponentProps<'h3'>;

Expand All@@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) {
'data-cl-slot': 'accordion-header',
};

return renderElement({
return useRender({
defaultTagName: 'h3',
render,
props: mergeProps<'h3'>(defaultProps, otherProps),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { useMemo } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';

export interface AccordionItemProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { resetLayoutStyles } from '../../utils/reset-layout-styles';
import { useAccordionItemContext } from './accordion-context';

Expand All@@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
// Merge the consumer ref with the internal panelRef so passing a ref does not
// clobber the ref the panel relies on for height measurement.
const combinedRef = useMergeRefs([panelRef, ref]);
const [height, setHeight] = useState<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand DownExpand Up@@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All@@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react';
import React, { type ReactNode, useCallback, useId, useMemo } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';

export interface AccordionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

return renderElement({
// Composite may inject a ref via compositeProps; hand it to useRender's ref
// param (which owns ref-merging) instead of leaving it in props, where
// useRender's merged ref would overwrite it.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's Composite invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the Composite fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'div',
render,
props: merged,
// SAFETY: mergeProps returns Record<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { CompositeItem } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';

export type AccordionTriggerProps = ComponentProps<'button'>;
Expand DownExpand Up@@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
// not override it, or the trigger/panel aria pairing would silently break.
merged.id = triggerId;

return renderElement({
// CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
// useRender's ref param (which owns ref-merging) instead of leaving it in props,
// where useRender's merged ref would overwrite it and break focus navigation.
const { ref: compositeRef, ...mergedProps } = merged;

// floating-ui's CompositeItem invokes this render callback synchronously and
// unconditionally during its own render (see renderJsx), so useRender runs in a
// stable hook position on the CompositeItem fiber. The rule can't see that.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useRender({
defaultTagName: 'button',
render,
// SAFETY: mergeProps returns Record<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteInputProps = ComponentProps<'input'>;
Expand All@@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All@@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
render,
// floating-ui types `setReference` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setReference, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => setInlineMode(false);
}, [setInlineMode]);

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };
Expand All@@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
props: merged,
})}
</FloatingList>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand All@@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All@@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All@@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;
Expand All@@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
'use client';

import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All@@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = useRender({
defaultTagName: 'div',
render,
// floating-ui types `setFloating` as a method signature, but at runtime it's
// a stable callback that doesn't use `this`, so the unbound-method check is a
// false positive here.
// eslint-disable-next-line @typescript-eslint/unbound-method
ref: [refs.setFloating, ref],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All@@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading