Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing useCompositeState with Ariakit #57304

Merged
merged 42 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
17a3f54
Implementing `useCompositeState` with Ariakit
andrewhayward Dec 21, 2023
47c0a6a
Updating CHANGELOG.md
andrewhayward Dec 21, 2023
1dcaa9b
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Dec 21, 2023
0fd8a12
Using `useId` to generate base IDs
andrewhayward Jan 3, 2024
759956b
Simplifying implementation
andrewhayward Jan 3, 2024
3e97c18
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Jan 3, 2024
371c141
Undoing merge artefact
andrewhayward Jan 3, 2024
37d604d
Reorganising
andrewhayward Jan 4, 2024
ac37fc2
Removing `isRTL` default arg
andrewhayward Jan 4, 2024
3da8c2f
Updating deprecation messages
andrewhayward Jan 4, 2024
98e4005
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Jan 11, 2024
a675ad5
Including deprecation warnings in test runs
andrewhayward Jan 12, 2024
778d69d
Switching `Parameters` for `ComponentProps`
andrewhayward Jan 14, 2024
00f352a
Using `string` in place of `PropertyKey`
andrewhayward Jan 14, 2024
ea9bcb5
Adding comments to `transform` function.
andrewhayward Jan 14, 2024
25c30e2
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Jan 14, 2024
3f16fd7
Renaming types/functions for clarity
andrewhayward Jan 15, 2024
2dc8329
Putting `.render.name` back in deprecation message
andrewhayward Jan 15, 2024
e56959e
Assigning `id` earlier to fix tests
andrewhayward Jan 15, 2024
14e93f9
General tidying
andrewhayward Jan 15, 2024
5622f48
Reverting merge artefacts
andrewhayward Jan 15, 2024
686ba88
Switching `Component` to `React.FunctionComponent`
andrewhayward Jan 22, 2024
28c5b63
Tidying legacy docs
andrewhayward Jan 23, 2024
68e4c7d
Adding subcomponents to legacy composite
andrewhayward Jan 23, 2024
2f73fb3
Tidying current docs
andrewhayward Jan 23, 2024
096fbf3
Tidying legacy docs
andrewhayward Jan 23, 2024
132a7cd
Adding Ariakit links to `current` component
andrewhayward Jan 23, 2024
f456c74
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Jan 24, 2024
8a44131
Restoring `clientHeight` mock
andrewhayward Jan 24, 2024
cdd4248
Changing note in `unstable` docblock
andrewhayward Jan 24, 2024
5d54335
`LegacyStateOptions` rebranding
andrewhayward Jan 24, 2024
aa2e322
Putting changelog entry in correct location
andrewhayward Jan 24, 2024
030580a
Tidying
andrewhayward Jan 26, 2024
4550f96
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Jan 30, 2024
07efd5f
Adding 'private' badge to "current" story
andrewhayward Jan 30, 2024
d43e02d
Tidying stories
andrewhayward Jan 30, 2024
68cfe2b
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Feb 1, 2024
dd6ba63
Reverting merge artefact
andrewhayward Feb 1, 2024
3bf02ad
Renaming Storybook entries for Composite components
andrewhayward Feb 1, 2024
acf897c
Merge branch 'trunk' into 56548/useCompositeState-legacy-implementation
andrewhayward Feb 2, 2024
26d4de8
Reverting merge artefact
andrewhayward Feb 2, 2024
76e4c7b
Removing custom inputs in legacy stories
andrewhayward Feb 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- `DropdownMenuV2`: do not collapse suffix width ([#57238](https://github.com/WordPress/gutenberg/pull/57238)).
- `DateTimePicker`: Adjustment of the dot position on DayButton and expansion of the button area. ([#55502](https://github.com/WordPress/gutenberg/pull/55502)).
- `Modal`: Improve application of body class names ([#55430](https://github.com/WordPress/gutenberg/pull/55430)).
- `Composite`: Implementing `useCompositeState` with Ariakit ([#57304](https://github.com/WordPress/gutenberg/pull/57304))

### Experimental

Expand Down
301 changes: 301 additions & 0 deletions packages/components/src/composite/legacy.tsx
ciampo marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
/**
* Composite is a component that may contain navigable items represented by
* CompositeItem. It's inspired by the WAI-ARIA Composite Role and implements
* all the keyboard navigation mechanisms to ensure that there's only one
* tab stop for the whole Composite element. This means that it can behave as
* a roving tabindex or aria-activedescendant container.
*
* @see https://ariakit.org/components/composite
*/

/**
* WordPress dependencies
*/
import { forwardRef, useMemo, useRef, useState } from '@wordpress/element';
import deprecated from '@wordpress/deprecated';

/**
* Internal dependencies
*/
import * as Current from './v2';

type CompositeStore = ReturnType< typeof Current.useCompositeStore >;
type CompositeStoreProps = NonNullable<
Parameters< typeof Current.useCompositeStore >[ 0 ]
>;

type Orientation = 'horizontal' | 'vertical';

interface IdState {
baseId: string;
setBaseId: React.Dispatch< React.SetStateAction< IdState[ 'baseId' ] > >;
}

export interface InitialState {
baseId?: string;
unstable_virtual?: boolean;
rtl?: boolean;
orientation?: Orientation;
currentId?: string;
loop?: boolean | Orientation;
wrap?: boolean | Orientation;
shift?: boolean;
}

export interface CompositeState {
store: CompositeStore;

up: () => void;
down: () => void;
first: () => void;
last: () => void;
previous: () => void;
next: () => void;
move: ( id: string | undefined ) => void;

baseId: IdState[ 'baseId' ];
setBaseId: ( v: CompositeState[ 'baseId' ] ) => void;
currentId: string | undefined;
setCurrentId: ( v: CompositeState[ 'currentId' ] ) => void;
orientation: Orientation | undefined;
setOrientation: ( v: CompositeState[ 'orientation' ] ) => void;
rtl: boolean;
setRTL: ( v: CompositeState[ 'rtl' ] ) => void;
loop: boolean | Orientation;
setLoop: ( v: CompositeState[ 'loop' ] ) => void;
shift: boolean;
setShift: ( v: CompositeState[ 'shift' ] ) => void;
wrap: boolean | Orientation;
setWrap: ( v: CompositeState[ 'wrap' ] ) => void;
}

type FilteredCompositeStateProps = Pick< CompositeState, 'store' | 'baseId' >;

type PartialCompositeState = Partial<
Omit< CompositeState, keyof FilteredCompositeStateProps >
> &
FilteredCompositeStateProps;

export type CompositeStateProps =
| { state: PartialCompositeState }
| ( PartialCompositeState & { state?: never } );

interface IdState {
baseId: string;
setBaseId: React.Dispatch< React.SetStateAction< string > >;
}

function showDeprecationMessage( ...props: Parameters< typeof deprecated > ) {
if ( 'test' !== process.env.NODE_ENV ) {
deprecated( ...props );
}
andrewhayward marked this conversation as resolved.
Show resolved Hide resolved
}

const idMap = new Map< string, React.MutableRefObject< number > >();

function useBaseId(
initialState: Pick< Partial< IdState >, 'baseId' > = {}
): IdState {
const { baseId: initialBaseId } = initialState;
const [ baseId, setBaseId ] = useState(
() =>
initialBaseId ||
// eslint-disable-next-line no-restricted-syntax
`id-${ Math.random().toString( 32 ).substring( 2, 8 ) }`
andrewhayward marked this conversation as resolved.
Show resolved Hide resolved
);
idMap.set( baseId, useRef( 0 ) );
return { baseId, setBaseId };
}

function useId( baseId: string, preferredId?: string ) {
const counter = idMap.get( baseId ) ?? { current: 0 };

const [ id, setId ] = useState( () => {
if ( preferredId ) return preferredId;
return `${ baseId }-${ ++counter.current }`;
} );

return { id, setId };
}

function useMapInitialStateToStoreProps(
initialState: InitialState = {}
): CompositeStoreProps & IdState {
const {
currentId: defaultActiveId,
orientation,
rtl = false,
loop: focusLoop = false,
wrap: focusWrap = false,
shift: focusShift = false,
// eslint-disable-next-line camelcase
unstable_virtual: virtualFocus,
...idState
} = initialState;

return {
...useBaseId( idState ),
defaultActiveId,
rtl,
orientation,
focusLoop,
focusShift,
focusWrap,
virtualFocus,

// TODO?
includesBaseElement: false,
andrewhayward marked this conversation as resolved.
Show resolved Hide resolved
};
}

function useCreateStoreFromInitialState(
initialState: InitialState = {}
): FilteredCompositeStateProps & IdState {
const { baseId, setBaseId, ...storeProps } =
useMapInitialStateToStoreProps( initialState );
const store = Current.useCompositeStore( storeProps );
return { store, baseId, setBaseId };
}

function useStateBuilder<
Store extends CompositeStore,
Config extends Record< Key, Value >,
Key extends Parameters< Store[ 'setState' ] >[ 0 ],
Value extends [ string, string ],
>( store: Store, config: Partial< Config > ) {
const meta = {} as Record< string, any >;
for ( const [ key, [ get, set ] ] of Object.entries< Value >(
config as Config
) ) {
meta[ get ] = store.useState( key as Key );
meta[ set ] = (
value: Parameters< typeof store.setState< Key > >[ 1 ]
) => {
store.setState< Key >( key as Key, value );
};
}
return meta;
}
andrewhayward marked this conversation as resolved.
Show resolved Hide resolved

function useCreateStateFromStore( {
store,
baseId,
setBaseId,
}: FilteredCompositeStateProps & IdState ): CompositeState {
const state = useStateBuilder( store, {
activeId: [ 'currentId', 'setCurrentId' ],
orientation: [ 'orientation', 'setOrientation' ],
rtl: [ 'rtl', 'setRTL' ],
focusLoop: [ 'loop', 'setLoop' ],
focusShift: [ 'shift', 'setShift' ],
focusWrap: [ 'wrap', 'setWrap' ],
} );

return useMemo(
() =>
( {
up: () => store.move( store.up() ),
down: () => store.move( store.down() ),
first: () => store.move( store.first() ),
last: () => store.move( store.last() ),
previous: () => store.move( store.previous() ),
next: () => store.move( store.next() ),
andrewhayward marked this conversation as resolved.
Show resolved Hide resolved
move: ( id: Parameters< CompositeStore[ 'move' ] >[ 0 ] ) =>
store.move( id ),
...state,
baseId,
setBaseId,
store,
} ) as CompositeState,
[ baseId, setBaseId, state, store ]
);
}

function filterProps(
props: CompositeStateProps
): FilteredCompositeStateProps {
if ( props.state ) {
const { state, ...rest } = props;
return { ...filterProps( state ), ...rest };
}

const {
up,
down,
first,
last,
previous,
next,
move,

setBaseId,
currentId,
setCurrentId,
orientation,
setOrientation,
rtl,
setRTL,
loop,
setLoop,
shift,
setShift,
wrap,
setWrap,

state,

...rest
} = props;

return rest;
}

function proxyComposite< T extends ( ...any: any[] ) => any >(
LegacyComponent: T | ( ( ...args: any[] ) => T ),
propMap: Record< PropertyKey, PropertyKey | null > = {}
): (
props: CompositeStateProps & Record< PropertyKey, any >
) => React.ReactElement {
return ( originalProps ) => {
const componentName =
// @ts-ignore
andrewhayward marked this conversation as resolved.
Show resolved Hide resolved
LegacyComponent.displayName ?? LegacyComponent.render.name;

showDeprecationMessage( componentName, {
alternative: `@wordpress/components:${ componentName }`,
andrewhayward marked this conversation as resolved.
Show resolved Hide resolved
} );

const { store, ...rest } = filterProps( originalProps );
const props = rest as Record< PropertyKey, any >;
const { baseId } = props;

Object.entries( propMap ).forEach( ( [ from, to ] ) => {
if ( props.hasOwnProperty( from ) ) {
if ( to ) props[ to ] = props[ from ];
delete props[ from ];
}
} );

props.id = useId( baseId, props.id ).id;
delete props.baseId;

return <LegacyComponent { ...props } store={ store } />;
};
}

export const Composite = proxyComposite( Current.Composite, { baseId: 'id' } );

export const CompositeGroup = proxyComposite(
forwardRef( function CompositeGroup( { role, ...props }, ref ) {
const Component =
role === 'row' ? Current.CompositeRow : Current.CompositeGroup;
return <Component ref={ ref } role={ role } { ...props } />;
} )
);

export const CompositeItem = proxyComposite( Current.CompositeItem, {
focusable: 'accessibleWhenDisabled',
} );

export const useCompositeState = ( initialState?: InitialState ) =>
useCreateStateFromStore( useCreateStoreFromInitialState( initialState ) );