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

Add non-listbox functionality back to CircularOptionPicker #54290

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b976cb3
Refactoring `CircularOptionPicker`
Sep 7, 2023
a5ef383
Updating `CircularOptionPicker` consumers
Sep 8, 2023
4d9c7df
Merge branch 'trunk' into 54239/allow-circularoptionpicker-non-listbox
Sep 8, 2023
158207d
Updating CHANGELOG.md
Sep 8, 2023
8de8875
Fixing circular dependency
Sep 8, 2023
bd811c1
Merge branch 'trunk' into 54239/allow-circularoptionpicker-non-listbox
Sep 12, 2023
d9cac8f
Updating subcomponent filenames
Sep 12, 2023
954932b
Simplifying `selectedIconProps` logic
Sep 13, 2023
e3ab6d2
Refactoring `commonProps`
Sep 13, 2023
61c6762
Refactoring render functions into components
Sep 13, 2023
a65f483
Letting `Button` deal with classes
Sep 13, 2023
b446436
Splitting state props into multiple effects
Sep 13, 2023
33ef3a8
Reverting `disableLooping` prop to `loop`
Sep 13, 2023
77e38f7
Adding note explaining `aria-pressed={null}`
Sep 13, 2023
96acbc5
Fixing some typing, removing `any`
Sep 13, 2023
08dca39
Reverting back to manual `is-pressed` style
Sep 13, 2023
056393f
Merge branch 'trunk' into 54239/allow-circularoptionpicker-non-listbox
Sep 13, 2023
eb916e0
Refactoring `Omit`
Sep 13, 2023
5eb54b2
Updating `color-palette` snapshot
Sep 13, 2023
be7ce54
Wrapping Option implementations with `forwardRef`
Sep 13, 2023
87cafaf
Overriding `WithLoopingDisabled` code sample to show `loop`
Sep 14, 2023
0e4eb2a
Refactoring `asButtons` typing
Sep 15, 2023
880a1a1
Refactoring `compositeState` accessors
Sep 15, 2023
3deef13
Updating various README docs with new props
Sep 15, 2023
6fd9ba6
Adding note to explain class additions
Sep 15, 2023
2f55f86
Refactoring `listbox` implementation to keep `children` out of option…
Sep 15, 2023
a7b82e8
Re-refactoring `listbox` implementation
Sep 15, 2023
87a3b2b
Removing redundant `@default`
Sep 15, 2023
5007d78
Merge branch 'trunk' into 54239/allow-circularoptionpicker-non-listbox
ciampo Sep 15, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -199,43 +199,46 @@ exports[`ColorPaletteControl matches the snapshot 1`] = `
</div>
</div>
<div
aria-label="Custom color picker."
class="components-circular-option-picker"
id="option-picker-0"
role="listbox"
>
<div
class="components-circular-option-picker__swatches"
aria-label="Custom color picker."
id="components-circular-option-picker-0"
role="listbox"
>
<div
class="components-circular-option-picker__option-group components-circular-option-picker__swatches"
class="components-circular-option-picker__swatches"
>
<div
class="components-circular-option-picker__option-wrapper"
class="components-circular-option-picker__option-group components-circular-option-picker__swatches"
>
<button
aria-label="Color: red"
aria-selected="true"
class="components-button components-circular-option-picker__option is-pressed"
id="option-picker-0-0"
role="option"
style="background-color: rgb(255, 0, 0); color: rgb(255, 0, 0);"
tabindex="0"
type="button"
/>
<svg
aria-hidden="true"
fill="#000"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<div
class="components-circular-option-picker__option-wrapper"
>
<path
d="M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z"
<button
aria-label="Color: red"
aria-selected="true"
class="components-button components-circular-option-picker__option is-pressed"
id="components-circular-option-picker-0-0"
role="option"
style="background-color: rgb(255, 0, 0); color: rgb(255, 0, 0);"
tabindex="0"
type="button"
/>
</svg>
<svg
aria-hidden="true"
fill="#000"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z"
/>
</svg>
</div>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- `BorderControl`: Apply proper metrics and simpler text ([#53998](https://github.com/WordPress/gutenberg/pull/53998)).
- `FormTokenField`: Update styling for consistency and increased visibility ([#54402](https://github.com/WordPress/gutenberg/pull/54402)).
- `Tooltip`: Add new `hideOnClick` prop ([#54406](https://github.com/WordPress/gutenberg/pull/54406)).
- `CircularOptionPicker`: Add option to use previous non-listbox behaviour, for contexts where buttons are more appropriate than a list of options ([#54290](https://github.com/WordPress/gutenberg/pull/54290)).
- `DuotonePicker/ColorListPicker`: Adds appropriate labels to 'Duotone Filter' color pickers ([#54468](https://github.com/WordPress/gutenberg/pull/54468)).

### Bug Fix
Expand Down
14 changes: 14 additions & 0 deletions packages/components/src/circular-option-picker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ The child elements.

- Required: No

### `asButtons`: `boolean`

Whether the control should present as a set of buttons, each with its own tab stop.

- Required: No
- Default: `false`

### `loop`: `boolean`

Prevents keyboard interaction from wrapping around. Only used when `asButtons` is not true.

- Required: No
- Default: `true`

## Subcomponents

### `CircularOptionPicker.ButtonAction`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* Internal dependencies
*/
import Button from '../button';
import Dropdown from '../dropdown';
import type { DropdownLinkActionProps } from './types';
import type { WordPressComponentProps } from '../ui/context';
import type { ButtonAsButtonProps } from '../button/types';

export function DropdownLinkAction( {
buttonProps,
className,
dropdownProps,
linkText,
}: DropdownLinkActionProps ) {
return (
<Dropdown
className={ classnames(
'components-circular-option-picker__dropdown-link-action',
className
) }
renderToggle={ ( { isOpen, onToggle } ) => (
<Button
aria-expanded={ isOpen }
aria-haspopup="true"
onClick={ onToggle }
variant="link"
{ ...buttonProps }
>
{ linkText }
</Button>
) }
{ ...dropdownProps }
/>
);
}

export function ButtonAction( {
className,
children,
...additionalProps
}: WordPressComponentProps< ButtonAsButtonProps, 'button', false > ) {
return (
<Button
className={ classnames(
'components-circular-option-picker__clear',
className
) }
variant="tertiary"
{ ...additionalProps }
>
{ children }
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* WordPress dependencies
*/
import { createContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { CircularOptionPickerContextProps } from './types';

export const CircularOptionPickerContext =
createContext< CircularOptionPickerContextProps >( {} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* Internal dependencies
*/
import type { OptionGroupProps } from './types';

export function OptionGroup( {
className,
options,
...additionalProps
}: OptionGroupProps ) {
const role =
'aria-label' in additionalProps || 'aria-labelledby' in additionalProps
? 'group'
: undefined;

return (
<div
{ ...additionalProps }
role={ role }
className={ classnames(
'components-circular-option-picker__option-group',
'components-circular-option-picker__swatches',
className
) }
>
{ options }
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type { ForwardedRef } from 'react';

/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
import { forwardRef, useContext, useEffect } from '@wordpress/element';
import { Icon, check } from '@wordpress/icons';

/**
* Internal dependencies
*/
import { CircularOptionPickerContext } from './circular-option-picker-context';
import Button from '../button';
import { CompositeItem } from '../composite';
import Tooltip from '../tooltip';
import type {
OptionProps,
CircularOptionPickerCompositeState,
CircularOptionPickerContextProps,
} from './types';

const hasSelectedOption = new Map();

function UnforwardedOptionAsButton(
props: {
id?: string;
className?: string;
isPressed?: boolean;
},
forwardedRef: ForwardedRef< any >
) {
return <Button { ...props } ref={ forwardedRef }></Button>;
}

const OptionAsButton = forwardRef( UnforwardedOptionAsButton );

function UnforwardedOptionAsOption(
props: {
id: string;
className?: string;
isSelected?: boolean;
context: CircularOptionPickerContextProps;
},
forwardedRef: ForwardedRef< any >
) {
const { id, className, isSelected, context, ...additionalProps } = props;
const { isComposite, ..._compositeState } = context;
const compositeState =
_compositeState as CircularOptionPickerCompositeState;
const { baseId, currentId, setCurrentId } = compositeState;

useEffect( () => {
// If we call `setCurrentId` here, it doesn't update for other
// Option renders in the same pass. So we have to store our own
// map to make sure that we only set the first selected option.
// We still need to check `currentId` because the control will
// update this as the user moves around, and that state should
// be maintained as the group gains and loses focus.
if ( isSelected && ! currentId && ! hasSelectedOption.get( baseId ) ) {
hasSelectedOption.set( baseId, true );
setCurrentId( id );
}
}, [ baseId, currentId, id, isSelected, setCurrentId ] );

return (
<CompositeItem
{ ...additionalProps }
{ ...compositeState }
as={ Button }
id={ id }
// Ideally we'd let the underlying `Button` component
// handle this by passing `isPressed` as a prop.
// Unfortunately doing so also sets `aria-pressed` as
// an attribute on the element, which is incompatible
// with `role="option"`, and there is no way at this
// point to override that behaviour.
className={ classnames( className, {
'is-pressed': isSelected,
} ) }
role="option"
aria-selected={ !! isSelected }
ref={ forwardedRef }
/>
);
}

const OptionAsOption = forwardRef( UnforwardedOptionAsOption );

export function Option( {
className,
isSelected,
selectedIconProps = {},
tooltipText,
...additionalProps
}: OptionProps ) {
const compositeContext = useContext( CircularOptionPickerContext );
const { isComposite, baseId } = compositeContext;
const id = useInstanceId(
Option,
baseId || 'components-circular-option-picker__option'
);

const commonProps = {
id,
className: 'components-circular-option-picker__option',
...additionalProps,
};

const optionControl = isComposite ? (
<OptionAsOption
{ ...commonProps }
context={ compositeContext }
isSelected={ isSelected }
/>
) : (
<OptionAsButton { ...commonProps } isPressed={ isSelected } />
);

return (
<div
className={ classnames(
className,
'components-circular-option-picker__option-wrapper'
) }
>
{ tooltipText ? (
<Tooltip text={ tooltipText }>{ optionControl }</Tooltip>
) : (
optionControl
) }
{ isSelected && <Icon icon={ check } { ...selectedIconProps } /> }
</div>
);
}
Loading