Skip to content

Commit

Permalink
[feature] RAC Navigation component
Browse files Browse the repository at this point in the history
  • Loading branch information
ktabors committed Nov 24, 2024
1 parent ab9fd5c commit b1a10d5
Show file tree
Hide file tree
Showing 3 changed files with 300 additions and 0 deletions.
183 changes: 183 additions & 0 deletions packages/react-aria-components/src/Navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {ContextValue, Provider, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {DOMAttributes, DOMProps, forwardRefType, Key, MultipleSelection} from '@react-types/shared';
import {filterDOMProps, useId} from '@react-aria/utils';
import {HeaderContext} from './Header';
import {LinkContext} from './Link';
import {Node} from 'react-stately';
import {Orientation} from 'react-aria';
import React, {createContext, ForwardedRef, forwardRef, ReactElement, ReactNode} from 'react';

// TODO: Replace NavigationRenderProps with AriaNavigationProps, once it exists
export interface NavigationProps extends NavigationRenderProps, /* RenderProps<NavigationRenderProps>, */ DOMProps, SlotProps {
/** Whether the navigation is disabled. */
isDisabled?: boolean,
/** Handler that is called when a navigation item is clicked. */
onAction?: (key: Key) => void
}

export interface NavigationRenderProps {
/**
* The orientation of the navigation.
* @selector [data-orientation="horizontal | vertical"]
*/
orientation: Orientation
}

export const NavigationContext = createContext<ContextValue<NavigationProps, HTMLUListElement>>(null);

function Navigation(props: NavigationProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, NavigationContext);

let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-Navigation',
values: {isDisabled: props.isDisabled}
});

let domProps = filterDOMProps(props);

return (
<nav
aria-label="Navigation"
{...domProps}
{...renderProps}
ref={ref}
data-disabled={props.isDisabled || undefined}>
<NavigationContext.Provider value={props}>
<ul
className="react-aria-NavigationList"
data-orientation={props.orientation || 'horizontal'}>
{renderProps.children}
</ul>
</NavigationContext.Provider>
</nav>
);

}

// TODO: Remove NavigationItemRenderProps and use AriaNavigationItemProps when it exists
export interface NavigationItemProps extends NavigationItemRenderProps, RenderProps<NavigationItemRenderProps> {
/** A unique id for the navigation item, which will be passed to `onAction` when the breadcrumb is pressed. */
id?: Key
}

export interface NavigationItemRenderProps {
/**
* Whether the navigation item is for the current page.
* @selector [data-current]
*/
isCurrent: boolean,
/**
* Whether the navigation item is disabled.
* @selector [data-disabled]
*/
isDisabled: boolean
}

// TODO: Does this need a context?

function NavigationItem(props: NavigationItemProps, ref: ForwardedRef<HTMLDivElement>) {
let {
id,
isCurrent,
...otherProps
} = props;
let {isDisabled, onAction} = useSlottedContext(NavigationContext)!;

// Generate an id if one wasn't provided.
// (can't pass id into useId since it can also be a number)
let defaultId = useId();
id ||= defaultId;

let linkProps = {
'aria-current': isCurrent ? 'page' : null,
isDisabled: isDisabled || otherProps.isDisabled || isCurrent,
onPress: () => onAction?.(id)
};

let renderProps = useRenderProps({
...otherProps,
defaultClassName: 'react-aria-NavigationItem',
values: {
isDisabled: isDisabled || isCurrent,
isCurrent
}
});

let domProps = filterDOMProps(otherProps as any);

return (
<li
{...domProps}
{...renderProps}
ref={ref}
data-current={isCurrent || undefined}
data-disabled={isDisabled || isCurrent || undefined}>
<LinkContext.Provider value={linkProps}>
{renderProps.children}
</LinkContext.Provider>
</li>
);
}

// TODO: Remove the Node<T>
export interface NavigationSectionProps<T> extends Node<T>, MultipleSelection {
// TODO: describe or refactor
children: ReactNode | ((item: object) => ReactElement)
}

function NavigationSection<T extends object>(props: NavigationSectionProps<T>, ref: ForwardedRef<HTMLElement>, section: Node<T>, className = 'react-aria-MenuSection') {
// TODO: State for selection
let [headingRef] = useSlot();
// TODO: Do we need a use hook for NavigationSection?
let renderProps = useRenderProps({
defaultClassName: className,
// className: section.props?.className,
// style: section.props?.style,
values: {}
});

return (
<section
{...filterDOMProps(props as any)}
{...renderProps}
ref={ref}>
<Provider
values={[
[HeaderContext, {...{role: 'presentation'} as DOMAttributes, ref: headingRef}]
]}>
{props.children}
</Provider>
</section>
);
}

/**
* A navigation is a grouping of navigation links.
*/
const _Navigation = /*#__PURE__*/ (forwardRef as forwardRefType)(Navigation);
export {_Navigation as Navigation};

/**
* A navigation item is a navigation link.
*/
const _NavigationItem = /*#__PURE__*/ (forwardRef as forwardRefType)(NavigationItem);
export {_NavigationItem as NavigationItem};

/**
* A NavigationSection represents a section within a navigation.
*/
const _NavigationSection = /*#__PURE__*/ /*#__PURE__*/ (forwardRef as forwardRefType)(NavigationSection);
export {_NavigationSection as NavigationSection};
91 changes: 91 additions & 0 deletions packages/react-aria-components/stories/Navigation.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {Button} from '../src/Button';
import {Disclosure, DisclosurePanel} from '../src/Disclosure';
import {Header} from '../src/Header';
import {Link} from '../src/Link';
import {Navigation, NavigationItem, NavigationSection} from '../src/Navigation';
import React from 'react';
import './styles.css';

export default {
title: 'React Aria Components',
component: Navigation,
args: {
orientation: 'vertical'
},
argTypes: {
isDisabled: {
control: 'boolean'
},
orientation: {
control: 'radio',
options: ['horizontal', 'vertical']
}
}
};

export const NavigationExample = (args: any) => (
<Navigation {...args}>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/releases/index.html">Releases</Link>
</NavigationItem>
<NavigationSection>
<Header>Libraries</Header>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/">Internationslized</Link>
</NavigationItem>
<NavigationItem isCurrent>
<Link href="//react-spectrum.adobe.com/">React Spectrum</Link>
</NavigationItem>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/react-aria/">React Aria</Link>
</NavigationItem>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/react-state/">React Stately</Link>
</NavigationItem>
<NavigationItem isDisabled>
<Link href="//react-spectrum.adobe.com/s2/">React Spectrum 2</Link>
</NavigationItem>
</NavigationSection>
<NavigationSection>
<Disclosure>
{({isExpanded}) => (
<>
<Header>
<Button slot="trigger">{isExpanded ? '⬇️' : '➡️'} React Aria Components</Button>
</Header>
<DisclosurePanel>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/react-aria/Button.html">Button</Link>
</NavigationItem>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/react-aria/Disclosure.html">Disclosure</Link>
</NavigationItem>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/react-aria/Button.html">Button</Link>
</NavigationItem>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/react-aria/Link.html">Link</Link>
</NavigationItem>
<NavigationItem>
<Link href="//react-spectrum.adobe.com/react-aria/Menu.html">Menu</Link>
</NavigationItem>
</DisclosurePanel>
</>
)}
</Disclosure>
</NavigationSection>
</Navigation>
);

26 changes: 26 additions & 0 deletions packages/react-aria-components/stories/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,29 @@
align-items: center
}
}


:global(.react-aria-Navigation) {
width: fit-content;

:global(.react-aria-NavigationList) {
display: flex;
flex-direction: column;
gap: 8px;
list-style-type: none;
padding: 0;

[data-current] {
font-weight: bold;
opacity: 1;
}

&[data-orientation=horizontal] {
flex-direction: row;
}
}

&[data-disabled], [data-disabled] {
opacity: 0.4;
}
}

0 comments on commit b1a10d5

Please sign in to comment.