Skip to content

Commit fd74cb8

Browse files
committed
feat: new navigation api
1 parent 68400d8 commit fd74cb8

File tree

16 files changed

+1102
-171
lines changed

16 files changed

+1102
-171
lines changed

.changeset/soft-oranges-double.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": minor
3+
---
4+
5+
The new navigation API that relies on external `useHref` and `useNavigation` hooks.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ yarn-error.log
1616
coverage
1717
size-limit-report/stats.json
1818
docs-static
19-
19+
*.spec.md

src/components/Root.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ModalProvider } from 'react-aria';
33
import { StyleSheetManager } from 'styled-components';
44

55
import { Provider } from '../provider';
6+
import { NavigationAdapter } from '../providers/navigation.types';
67
import { TrackingProps, TrackingProvider } from '../providers/TrackingProvider';
78
import {
89
BASE_STYLES,
@@ -43,7 +44,7 @@ export interface CubeRootProps extends BaseProps {
4344
fontDisplay?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional';
4445
fonts?: boolean;
4546
publicUrl?: string;
46-
router?: any;
47+
navigation?: NavigationAdapter;
4748
font?: string;
4849
monospaceFont?: string;
4950
applyLegacyTokens?: boolean;
@@ -64,7 +65,7 @@ export function Root(allProps: CubeRootProps) {
6465
fontDisplay = 'swap',
6566
fonts,
6667
publicUrl,
67-
router,
68+
navigation,
6869
font,
6970
monospaceFont,
7071
applyLegacyTokens,
@@ -125,7 +126,7 @@ export function Root(allProps: CubeRootProps) {
125126
const styles = extractStyles(props, STYLES, DEFAULT_STYLES);
126127

127128
return (
128-
<Provider router={router} root={rootRef} breakpoints={breakpoints}>
129+
<Provider navigation={navigation} root={rootRef} breakpoints={breakpoints}>
129130
<TrackingProvider event={tracking?.event}>
130131
<StyleSheetManager>
131132
<RootElement
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { Meta, Story, Controls } from '@storybook/addon-docs/blocks';
2+
import { Link } from './Link';
3+
import * as LinkStories from './Link.stories';
4+
5+
<Meta of={LinkStories} />
6+
7+
# Link
8+
9+
Semantic navigation component styled as a textual link. It reuses `Button` under the hood with `type="link"`, but this page focuses on navigation via the `to` prop.
10+
11+
## When to Use
12+
13+
- Navigate inside your SPA using the configured router
14+
- Open destinations in a new tab
15+
- Jump to on‑page anchors via `#id`
16+
- Perform history navigation (back/forward/reload) using numbers
17+
- Bypass SPA routing and force a full page load
18+
19+
## Component
20+
21+
<Story of={LinkStories.Default} />
22+
23+
---
24+
25+
## Properties
26+
27+
<Controls />
28+
29+
### Base Properties
30+
31+
Supports [Base properties](/docs/tasty-base-properties--docs)
32+
33+
### Styling Properties
34+
35+
#### styles
36+
37+
Customizes the root element of the component.
38+
39+
Sub-elements:
40+
- `ButtonIcon` – wrapper around `icon` and `rightIcon` when used.
41+
42+
### Style Properties
43+
44+
These properties allow direct styling without using the `styles` prop: `width`, `height`.
45+
46+
---
47+
48+
## Link Syntax (`to` prop)
49+
50+
`to` defines where and how navigation happens. It accepts a string, an object path, or a number for history navigation.
51+
52+
### 1) String URL/Path (SPA push)
53+
54+
```jsx
55+
// Navigate to internal route via the navigation adapter
56+
<Link to="/pricing">Pricing</Link>
57+
58+
// External absolute URLs always use native navigation
59+
<Link to="https://cube.dev">Cube</Link>
60+
61+
// Relative paths work properly with router context
62+
<Link to="../parent-page">Parent</Link>
63+
```
64+
65+
### 2) Open in a new tab (`!` prefix)
66+
67+
```jsx
68+
// Open internal route in a new tab
69+
<Link to="!/changelog">Changelog</Link>
70+
71+
// Open external URL in a new tab
72+
<Link to="!https://cube.dev">Docs</Link>
73+
```
74+
75+
Notes:
76+
- Adds `target="_blank"` and `rel="noopener noreferrer"` automatically.
77+
- Cmd/Ctrl/Shift + click also opens in a new tab, like a native link.
78+
79+
### 3) Native navigation / full reload (`@` prefix)
80+
81+
```jsx
82+
// Bypass SPA router and perform a full page load
83+
<Link to="@/signin">Sign in</Link>
84+
85+
// External native navigation works as well
86+
<Link to="@https://cube.dev">Homepage</Link>
87+
```
88+
89+
### 4) On‑page anchors (hash links)
90+
91+
```jsx
92+
// Smoothly scrolls to the element with id="features"
93+
<Link to="#features">Jump to Features</Link>
94+
```
95+
96+
### 5) Object path (modern routers)
97+
98+
```jsx
99+
// Uses navigate(to) with object path support
100+
<Link
101+
to={{ pathname: '/search', search: 'q=cube', hash: 'top' }}
102+
>
103+
Advanced search
104+
</Link>
105+
```
106+
107+
Accepted object shape:
108+
```ts
109+
{ pathname?: string; search?: string; hash?: string }
110+
```
111+
112+
Notes:
113+
- `search` and `hash` are auto-prefixed with `?` and `#` if needed
114+
- `pathname` can be relative (e.g., `../parent`) or absolute (e.g., `/root`)
115+
- Works with `!` and `@` prefixes when passed as strings
116+
117+
### 6) History navigation (numbers)
118+
119+
```jsx
120+
// Go back one entry
121+
<Link to={-1}>Back</Link>
122+
123+
// Go forward one entry
124+
<Link to={1}>Forward</Link>
125+
126+
// Reload current entry (like location.reload but via history)
127+
<Link to={0}>Reload</Link>
128+
```
129+
130+
Behavior:
131+
- Uses `navigate(delta)` from the navigation adapter
132+
- The default adapter calls `window.history.go(delta)`
133+
134+
---
135+
136+
## Router Integration
137+
138+
`Link` uses a hook-based navigation adapter that you provide via the `Root` component. The adapter enables proper relative path resolution for new-tab navigation and modern router integration.
139+
140+
### Interface
141+
142+
```ts
143+
interface NavigationAdapter {
144+
useHref: (to: To, opts?: { relative?: 'route' | 'path' }) => string;
145+
useNavigate: () => (to: To | number, opts?: NavigateOptions) => void;
146+
}
147+
```
148+
149+
### React Router v6+ (Recommended)
150+
151+
React Router hooks are directly compatible with our `NavigationAdapter` interface:
152+
153+
```jsx
154+
import { Root } from '@cube-dev/ui-kit';
155+
import { useHref, useNavigate } from 'react-router-dom';
156+
157+
const navigation = { useHref, useNavigate };
158+
159+
export function App() {
160+
return (
161+
<Root navigation={navigation}>
162+
{/* Your app content */}
163+
</Root>
164+
);
165+
}
166+
```
167+
168+
### Custom adapter example
169+
170+
For other routers, create a custom adapter:
171+
172+
```jsx
173+
import { Root } from '@cube-dev/ui-kit';
174+
175+
function MyRouterAdapter() {
176+
return {
177+
useHref: (to) => {
178+
// Return resolved href for the given route
179+
return typeof to === 'string' ? to : `${to.pathname || ''}${to.search || ''}${to.hash || ''}`;
180+
},
181+
useNavigate: () => {
182+
return (to, options) => {
183+
if (typeof to === 'number') {
184+
window.history.go(to);
185+
} else {
186+
myRouter.navigate(to, options);
187+
}
188+
};
189+
},
190+
};
191+
}
192+
193+
export function App() {
194+
const navigation = MyRouterAdapter();
195+
return <Root navigation={navigation}>{/* Your app */}</Root>;
196+
}
197+
```
198+
199+
### No router (default behavior)
200+
201+
When no `navigation` prop is provided, `Link` falls back to native browser navigation:
202+
203+
```jsx
204+
import { Root } from '@cube-dev/ui-kit';
205+
206+
export function App() {
207+
return (
208+
<Root>
209+
{/* Links will use window.location for navigation */}
210+
</Root>
211+
);
212+
}
213+
```
214+
215+
Default adapter behavior:
216+
- String/object navigation uses `window.location.assign(href)`
217+
- History navigation uses `window.history.go(delta)`
218+
- `!` prefix opens resolved URLs in a new tab
219+
- `@` prefix forces native navigation to resolved URLs
220+
- Relative paths are resolved against the current page
221+
222+
---
223+
224+
## Navigation Options
225+
226+
Control navigation behavior via the `navigationOptions` prop:
227+
228+
```jsx
229+
<Link
230+
to="/search"
231+
navigationOptions={{
232+
replace: true,
233+
state: { from: 'homepage' },
234+
relative: 'path',
235+
preventScrollReset: true
236+
}}
237+
>
238+
Search (replace history)
239+
</Link>
240+
```
241+
242+
### Available options
243+
244+
- `replace`: Replace current history entry instead of pushing a new one
245+
- `state`: Navigation state data (router-specific)
246+
- `relative`: How relative paths are resolved (`'route'` | `'path'`)
247+
- `preventScrollReset`: Prevent scroll reset on navigation
248+
249+
These options are passed to the router's `navigate` function and have no effect on native navigation (`@` prefix, external URLs, or when no adapter is provided).
250+
251+
---
252+
253+
## Variants
254+
255+
`Link` inherits `theme` and `size` from `Button`. The visual `type` is fixed to `link`.
256+
257+
### Themes
258+
259+
- `default`, `danger`, `success`, `special`
260+
261+
### Sizes
262+
263+
- `xsmall`, `small`, `medium`, `large`, `xlarge`
264+
265+
---
266+
267+
## Examples
268+
269+
### Basic SPA push
270+
271+
```jsx
272+
<Link to="/about">About</Link>
273+
```
274+
275+
### Anchor and new tab
276+
277+
```jsx
278+
<Link to="#faq">FAQ</Link>
279+
<Link to="!https://cube.dev">Open Docs in new tab</Link>
280+
```
281+
282+
### Bypass SPA
283+
284+
```jsx
285+
<Link to="@/logout">Log out</Link>
286+
```
287+
288+
### History
289+
290+
```jsx
291+
<Link to={-1}>Back</Link>
292+
<Link to={0}>Reload</Link>
293+
```
294+
295+
---
296+
297+
## Accessibility
298+
299+
- Renders as an `<a>` element when `to` is a string/object; as a `<button>` when `to` is a number or omitted.
300+
- Keyboard interaction and focus management are handled by React Aria.
301+
- Provide `label`/`aria-label` when there is no visible text.
302+
303+
### ARIA Properties
304+
305+
- `aria-label`, `aria-labelledby` – Accessible name.
306+
- Additional ARIA attributes supported by React Aria's `useButton`.
307+
308+
---
309+
310+
## Best Practices
311+
312+
1. Prefer SPA push (`to="/path"`) for in‑app navigation; use `@` only when a full reload is required.
313+
2. Use `!` for new tabs instead of adding `target` props yourself; security attributes are added automatically.
314+
3. Use object `to` when your router supports structured navigation.
315+
4. For back/forward actions in UI, use numeric `to` for predictable history behavior.
316+
317+
## Suggested Improvements
318+
319+
- Add a dedicated `href` prop alias for clarity in link‑only scenarios.
320+
- Expose a `replace` option for SPA navigation.
321+
322+
## Related Components
323+
324+
- [Button](/docs/actions-button--docs) – Action component with multiple visual styles, including `type="link"`.
325+
326+
File renamed without changes.

0 commit comments

Comments
 (0)