@duro-app/ui 5.4.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Table-Cm5cCnZD.js → Table-DYN-bp0e.js} +388 -387
- package/dist/Table-DYN-bp0e.js.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts +37 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts +3 -0
- package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts +18 -0
- package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts.map +1 -0
- package/dist/components/Breadcrumb/styles.css.d.ts +46 -0
- package/dist/components/Breadcrumb/styles.css.d.ts.map +1 -0
- package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
- package/dist/components/LinkButton/LinkButton.d.ts +8 -1
- package/dist/components/LinkButton/LinkButton.d.ts.map +1 -1
- package/dist/components/LinkButton/LinkButton.stories.d.ts +9 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
- package/dist/components/PageNav/PageNav.d.ts +30 -0
- package/dist/components/PageNav/PageNav.d.ts.map +1 -0
- package/dist/components/PageNav/PageNav.meta.d.ts +3 -0
- package/dist/components/PageNav/PageNav.meta.d.ts.map +1 -0
- package/dist/components/PageNav/PageNav.stories.d.ts +16 -0
- package/dist/components/PageNav/PageNav.stories.d.ts.map +1 -0
- package/dist/components/PageNav/styles.css.d.ts +51 -0
- package/dist/components/PageNav/styles.css.d.ts.map +1 -0
- package/dist/components/TableOfContents/TableOfContents.d.ts +53 -0
- package/dist/components/TableOfContents/TableOfContents.d.ts.map +1 -0
- package/dist/components/TableOfContents/TableOfContents.meta.d.ts +3 -0
- package/dist/components/TableOfContents/TableOfContents.meta.d.ts.map +1 -0
- package/dist/components/TableOfContents/TableOfContents.stories.d.ts +21 -0
- package/dist/components/TableOfContents/TableOfContents.stories.d.ts.map +1 -0
- package/dist/components/TableOfContents/TableOfContentsContext.d.ts +11 -0
- package/dist/components/TableOfContents/TableOfContentsContext.d.ts.map +1 -0
- package/dist/components/TableOfContents/styles.css.d.ts +114 -0
- package/dist/components/TableOfContents/styles.css.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.d.ts +8 -1
- package/dist/components/TextLink/TextLink.d.ts.map +1 -1
- package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
- package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -1
- package/dist/components/Tree/Tree.d.ts +13 -1
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/Tree.meta.d.ts.map +1 -1
- package/dist/components/Tree/Tree.stories.d.ts +9 -0
- package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/components/Tree/styles.css.d.ts +5 -0
- package/dist/components/Tree/styles.css.d.ts.map +1 -1
- package/dist/docs/clickLink.d.ts +4 -0
- package/dist/docs/clickLink.d.ts.map +1 -0
- package/dist/docs/coarsePointer.d.ts +15 -0
- package/dist/docs/coarsePointer.d.ts.map +1 -0
- package/dist/docs/themedSurface.d.ts +8 -0
- package/dist/docs/themedSurface.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2945 -2484
- package/dist/index.js.map +1 -1
- package/dist/shared/navigate.d.ts +25 -0
- package/dist/shared/navigate.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Breadcrumb/Breadcrumb.meta.ts +37 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +110 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +95 -0
- package/src/components/Breadcrumb/styles.css.ts +76 -0
- package/src/components/Inline/Inline.meta.ts +2 -1
- package/src/components/LinkButton/LinkButton.meta.ts +1 -1
- package/src/components/LinkButton/LinkButton.stories.tsx +42 -1
- package/src/components/LinkButton/LinkButton.tsx +9 -0
- package/src/components/PageNav/PageNav.meta.ts +35 -0
- package/src/components/PageNav/PageNav.stories.tsx +146 -0
- package/src/components/PageNav/PageNav.tsx +93 -0
- package/src/components/PageNav/styles.css.ts +100 -0
- package/src/components/TableOfContents/TableOfContents.meta.ts +41 -0
- package/src/components/TableOfContents/TableOfContents.stories.tsx +222 -0
- package/src/components/TableOfContents/TableOfContents.tsx +173 -0
- package/src/components/TableOfContents/TableOfContentsContext.ts +20 -0
- package/src/components/TableOfContents/styles.css.ts +147 -0
- package/src/components/TextLink/TextLink.meta.ts +1 -1
- package/src/components/TextLink/TextLink.stories.tsx +42 -1
- package/src/components/TextLink/TextLink.tsx +9 -0
- package/src/components/Tree/Tree.meta.ts +2 -1
- package/src/components/Tree/Tree.stories.tsx +93 -1
- package/src/components/Tree/Tree.tsx +69 -27
- package/src/components/Tree/styles.css.ts +6 -0
- package/src/docs/clickLink.ts +24 -0
- package/src/docs/coarsePointer.ts +42 -0
- package/src/docs/themedSurface.tsx +23 -0
- package/src/index.ts +9 -0
- package/src/shared/linkParts.typecheck.tsx +47 -0
- package/src/shared/navigate.ts +62 -0
- package/dist/Table-Cm5cCnZD.js.map +0 -1
- package/dist/shared/length.typecheck.d.ts +0 -31
- package/dist/shared/length.typecheck.d.ts.map +0 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The part of a click event a link part reads and hands to `onNavigate`. */
|
|
2
|
+
export interface LinkClickEvent {
|
|
3
|
+
readonly button: number;
|
|
4
|
+
readonly metaKey: boolean;
|
|
5
|
+
readonly ctrlKey: boolean;
|
|
6
|
+
readonly shiftKey: boolean;
|
|
7
|
+
readonly altKey: boolean;
|
|
8
|
+
readonly defaultPrevented: boolean;
|
|
9
|
+
preventDefault(): void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Called for a plain primary click on a link part. Call
|
|
13
|
+
* `event.preventDefault()`, then your router's navigate; leave the event alone
|
|
14
|
+
* to let the browser follow the link.
|
|
15
|
+
*/
|
|
16
|
+
export type OnNavigate = (href: string, event: LinkClickEvent) => void;
|
|
17
|
+
/** A primary-button click with no modifier key, not already prevented. */
|
|
18
|
+
export declare function isPlainClick(event: LinkClickEvent): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The `onClick` of a link part: hands a plain click on a link with no
|
|
21
|
+
* `target` to `onNavigate`, and does nothing otherwise, so the browser default
|
|
22
|
+
* runs. Undefined when there is no `onNavigate` (no handler at all).
|
|
23
|
+
*/
|
|
24
|
+
export declare function linkClickHandler(href: string, onNavigate: OnNavigate | undefined, target?: string | null): ((event: LinkClickEvent) => void) | undefined;
|
|
25
|
+
//# sourceMappingURL=navigate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"navigate.d.ts","sourceRoot":"","sources":["../../src/shared/navigate.ts"],"names":[],"mappings":"AAgBA,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;IAClC,cAAc,IAAI,IAAI,CAAA;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,KAAK,IAAI,CAAA;AAEtE,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAS3D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,UAAU,GAAG,SAAS,EAClC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,GACrB,CAAC,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC,GAAG,SAAS,CAM/C"}
|
package/dist/table.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { M as F,
|
|
1
|
+
import { M as F, m as xt, R as rt, H as kt, n as pt, o as bt, p as ft, q as mt, k as ht, t as yt, r as St, v as Tt } from "./Table-DYN-bp0e.js";
|
|
2
2
|
import { jsx as u, jsxs as q } from "react/jsx-runtime";
|
|
3
3
|
import { c as H, m as p, d as b } from "./compiler-runtime-B2IcI8_O.js";
|
|
4
4
|
import { flexRender as E, useReactTable as it, getPaginationRowModel as Ct, getFilteredRowModel as vt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@tanstack/react-virtual": "^3.14.6",
|
|
69
|
-
"@duro-app/tokens": "^5.
|
|
69
|
+
"@duro-app/tokens": "^5.5.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Where the current page sits in the hierarchy: a labelled nav landmark with an ordered list of links, outermost first. An Item is either a link (href, optional onNavigate) or the current page (current: text with aria-current="page"), never both — the types refuse the mix. The › separators are aria-hidden. Links take href and an optional onNavigate(href, event) for a client router (plain clicks only). Compound component — Root is required.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'A page deep in a hierarchy — docs › section › page, admin › collection › record',
|
|
8
|
+
'A detail page whose way back to its collection should be one click',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'The previous and next pages in reading order — use PageNav',
|
|
12
|
+
'The sections of the page being read — use TableOfContents',
|
|
13
|
+
'Primary app navigation — use SideNav',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: ['Root', 'Item'],
|
|
17
|
+
},
|
|
18
|
+
relatedTo: [
|
|
19
|
+
{
|
|
20
|
+
component: 'PageNav',
|
|
21
|
+
kind: 'contrast',
|
|
22
|
+
relationship: 'Breadcrumb goes up the hierarchy; PageNav goes along the reading order',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
component: 'admin-detail-page',
|
|
26
|
+
kind: 'composition',
|
|
27
|
+
relationship: 'The admin-detail-page recipe heads the record with a Breadcrumb',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
example: `<Breadcrumb.Root aria-label="Breadcrumb">
|
|
31
|
+
<Breadcrumb.Item href="/docs" onNavigate={go}>Docs</Breadcrumb.Item>
|
|
32
|
+
<Breadcrumb.Item href="/docs/architecture" onNavigate={go}>Architecture</Breadcrumb.Item>
|
|
33
|
+
<Breadcrumb.Item current>AI-ops platform</Breadcrumb.Item>
|
|
34
|
+
</Breadcrumb.Root>
|
|
35
|
+
|
|
36
|
+
// go = (href, event) => { event.preventDefault(); navigate(href) }`,
|
|
37
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, fn, waitFor} from 'storybook/test'
|
|
3
|
+
import {Breadcrumb} from './Breadcrumb'
|
|
4
|
+
import {clickLink} from '../../docs/clickLink'
|
|
5
|
+
import {onThemeSurface} from '../../docs/themedSurface'
|
|
6
|
+
import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
|
|
7
|
+
import {SIZES_PX} from '@duro-app/tokens/keys'
|
|
8
|
+
import type {OnNavigate} from '../../shared/navigate'
|
|
9
|
+
|
|
10
|
+
const meta: Meta = {
|
|
11
|
+
title: 'Components/Breadcrumb',
|
|
12
|
+
parameters: {a11y: {test: 'error'}},
|
|
13
|
+
decorators: [onThemeSurface],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj
|
|
18
|
+
|
|
19
|
+
function Trail({onNavigate}: {onNavigate?: OnNavigate}) {
|
|
20
|
+
return (
|
|
21
|
+
<Breadcrumb.Root aria-label="Breadcrumb">
|
|
22
|
+
<Breadcrumb.Item href="#docs" onNavigate={onNavigate}>
|
|
23
|
+
Docs
|
|
24
|
+
</Breadcrumb.Item>
|
|
25
|
+
<Breadcrumb.Item href="#architecture" onNavigate={onNavigate}>
|
|
26
|
+
Architecture
|
|
27
|
+
</Breadcrumb.Item>
|
|
28
|
+
<Breadcrumb.Item current>AI-ops platform</Breadcrumb.Item>
|
|
29
|
+
</Breadcrumb.Root>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Default: Story = {
|
|
34
|
+
render: () => <Trail />,
|
|
35
|
+
play: async ({canvas}) => {
|
|
36
|
+
const nav = canvas.getByRole('navigation', {name: 'Breadcrumb'})
|
|
37
|
+
await expect(nav).toBeInTheDocument()
|
|
38
|
+
// the list holds the three items only: the separators are aria-hidden
|
|
39
|
+
await expect(canvas.getAllByRole('listitem')).toHaveLength(3)
|
|
40
|
+
await expect(canvas.getAllByRole('link').map((a) => a.textContent)).toEqual([
|
|
41
|
+
'Docs',
|
|
42
|
+
'Architecture',
|
|
43
|
+
])
|
|
44
|
+
// the current page is text, not a link, and says so
|
|
45
|
+
const current = canvas.getByText('AI-ops platform')
|
|
46
|
+
await expect(current).toHaveAttribute('aria-current', 'page')
|
|
47
|
+
await expect(current.closest('a')).toBeNull()
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const KeyboardOrder: Story = {
|
|
52
|
+
render: () => <Trail />,
|
|
53
|
+
play: async ({canvas, userEvent}) => {
|
|
54
|
+
await userEvent.tab()
|
|
55
|
+
await expect(canvas.getByRole('link', {name: 'Docs'})).toHaveFocus()
|
|
56
|
+
await userEvent.tab()
|
|
57
|
+
await expect(canvas.getByRole('link', {name: 'Architecture'})).toHaveFocus()
|
|
58
|
+
// the current page is not a tab stop
|
|
59
|
+
await userEvent.tab()
|
|
60
|
+
await expect(canvas.getByRole('link', {name: 'Architecture'})).not.toHaveFocus()
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const ClientNavigation: StoryObj<{onNavigate: OnNavigate}> = {
|
|
65
|
+
args: {onNavigate: fn()},
|
|
66
|
+
render: (args) => <Trail onNavigate={args.onNavigate} />,
|
|
67
|
+
play: async ({args, canvas}) => {
|
|
68
|
+
const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
|
|
69
|
+
onNavigate.mockImplementation((_href: string, event: {preventDefault(): void}) =>
|
|
70
|
+
event.preventDefault(),
|
|
71
|
+
)
|
|
72
|
+
const link = canvas.getByRole('link', {name: 'Architecture'})
|
|
73
|
+
|
|
74
|
+
// a plain click goes to the router, which prevents the default
|
|
75
|
+
await expect(clickLink(link).defaultPrevented).toBe(true)
|
|
76
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
77
|
+
await expect(onNavigate.mock.calls[0][0]).toBe('#architecture')
|
|
78
|
+
|
|
79
|
+
// a modified or non-primary click keeps the browser default
|
|
80
|
+
for (const init of [{metaKey: true}, {ctrlKey: true}, {shiftKey: true}, {button: 1}]) {
|
|
81
|
+
await expect(clickLink(link, init).defaultPrevented).toBe(false)
|
|
82
|
+
}
|
|
83
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A touch screen gets 44px links (coarse pointer); a mouse keeps the
|
|
89
|
+
* artboard's text-height row.
|
|
90
|
+
* It drives Chrome's touch emulation through Vitest's CDP session, so it runs
|
|
91
|
+
* as a test and stays out of the dev sidebar (there is no Vitest there).
|
|
92
|
+
*/
|
|
93
|
+
export const TouchTargets: Story = {
|
|
94
|
+
tags: ['!dev', '!autodocs'],
|
|
95
|
+
render: () => <Trail />,
|
|
96
|
+
play: async ({canvas}) => {
|
|
97
|
+
await withFinePointer()
|
|
98
|
+
const links = () => canvas.getAllByRole('link')
|
|
99
|
+
for (const link of links()) {
|
|
100
|
+
await expect(link.getBoundingClientRect().height).toBeLessThan(SIZES_PX.touchTarget)
|
|
101
|
+
}
|
|
102
|
+
await withCoarsePointer(async () => {
|
|
103
|
+
for (const link of links()) {
|
|
104
|
+
await waitFor(() =>
|
|
105
|
+
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(SIZES_PX.touchTarget),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
},
|
|
110
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {Children, createContext, Fragment, isValidElement, type ReactNode, useContext} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {styles} from './styles.css'
|
|
4
|
+
import {linkClickHandler, type OnNavigate} from '../../shared/navigate'
|
|
5
|
+
|
|
6
|
+
/* Breadcrumb — where the current page sits in the site's hierarchy: a
|
|
7
|
+
* labelled `nav` holding an ordered list of links, the last one the current
|
|
8
|
+
* page (`aria-current="page"`, shown as text). The separators between items
|
|
9
|
+
* are decorative (`aria-hidden`), so a screen reader hears the list alone. */
|
|
10
|
+
|
|
11
|
+
// Items render list items: outside Root's list they would be invalid markup.
|
|
12
|
+
const BreadcrumbContext = createContext(false)
|
|
13
|
+
|
|
14
|
+
function useBreadcrumb() {
|
|
15
|
+
if (!useContext(BreadcrumbContext))
|
|
16
|
+
throw new Error('Breadcrumb compound components must be used within Breadcrumb.Root')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// --- Root ---
|
|
20
|
+
|
|
21
|
+
interface RootProps {
|
|
22
|
+
/** Names the landmark ("Breadcrumb"); required, from your text catalog. */
|
|
23
|
+
'aria-label': string
|
|
24
|
+
/** `Breadcrumb.Item`s, outermost first. */
|
|
25
|
+
children: ReactNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Root({'aria-label': ariaLabel, children}: RootProps) {
|
|
29
|
+
const items = Children.toArray(children).filter(isValidElement)
|
|
30
|
+
return (
|
|
31
|
+
<BreadcrumbContext.Provider value={true}>
|
|
32
|
+
<html.nav aria-label={ariaLabel} style={styles.root}>
|
|
33
|
+
<html.ol style={styles.list}>
|
|
34
|
+
{items.map((item, i) => (
|
|
35
|
+
<Fragment key={item.key ?? i}>
|
|
36
|
+
{i > 0 ? (
|
|
37
|
+
<html.li aria-hidden style={styles.separator}>
|
|
38
|
+
›
|
|
39
|
+
</html.li>
|
|
40
|
+
) : null}
|
|
41
|
+
{item}
|
|
42
|
+
</Fragment>
|
|
43
|
+
))}
|
|
44
|
+
</html.ol>
|
|
45
|
+
</html.nav>
|
|
46
|
+
</BreadcrumbContext.Provider>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Item ---
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A link to an ancestor page, or the current page. The two are exclusive: the
|
|
54
|
+
* current page has no `href` (it is text) and a link is never `current`.
|
|
55
|
+
*/
|
|
56
|
+
type ItemProps = {children: ReactNode} & (
|
|
57
|
+
| {
|
|
58
|
+
/** Where the item links to. */
|
|
59
|
+
href: string
|
|
60
|
+
/**
|
|
61
|
+
* Client-side navigation: called for a plain primary click (no modifier
|
|
62
|
+
* key). Call `event.preventDefault()`, then your router's navigate.
|
|
63
|
+
*/
|
|
64
|
+
onNavigate?: OnNavigate
|
|
65
|
+
current?: never
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
/** The page the user is on: rendered as text with `aria-current="page"`. */
|
|
69
|
+
current: true
|
|
70
|
+
href?: never
|
|
71
|
+
onNavigate?: never
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
function Item({href, current, onNavigate, children}: ItemProps) {
|
|
76
|
+
useBreadcrumb()
|
|
77
|
+
return (
|
|
78
|
+
<html.li style={styles.item}>
|
|
79
|
+
{current ? (
|
|
80
|
+
<html.span aria-current="page" style={styles.current}>
|
|
81
|
+
{children}
|
|
82
|
+
</html.span>
|
|
83
|
+
) : (
|
|
84
|
+
<html.a href={href} onClick={linkClickHandler(href, onNavigate)} style={styles.link}>
|
|
85
|
+
{children}
|
|
86
|
+
</html.a>
|
|
87
|
+
)}
|
|
88
|
+
</html.li>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const Breadcrumb = {
|
|
93
|
+
Root,
|
|
94
|
+
Item,
|
|
95
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
6
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
7
|
+
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
8
|
+
|
|
9
|
+
// The picked docs browser (docs/mockups/docs-nav/Reader.dc.html, `.crumbs`):
|
|
10
|
+
// small muted text, accent links, a `›` between items, the current page plain.
|
|
11
|
+
export const styles = css.create({
|
|
12
|
+
root: {
|
|
13
|
+
minWidth: 0,
|
|
14
|
+
},
|
|
15
|
+
list: {
|
|
16
|
+
display: 'flex',
|
|
17
|
+
flexWrap: 'wrap',
|
|
18
|
+
alignItems: 'center',
|
|
19
|
+
gap: spacing.xs,
|
|
20
|
+
margin: 0,
|
|
21
|
+
padding: 0,
|
|
22
|
+
listStyleType: 'none',
|
|
23
|
+
fontFamily: typography.fontFamily,
|
|
24
|
+
fontSize: typography.fontSizeSm,
|
|
25
|
+
color: colors.textMuted,
|
|
26
|
+
},
|
|
27
|
+
item: {
|
|
28
|
+
display: 'inline-flex',
|
|
29
|
+
minWidth: 0,
|
|
30
|
+
},
|
|
31
|
+
separator: {
|
|
32
|
+
display: 'inline-flex',
|
|
33
|
+
color: colors.textMuted,
|
|
34
|
+
userSelect: 'none',
|
|
35
|
+
},
|
|
36
|
+
// On a touch screen (coarse pointer, as Toggle does) each link is a
|
|
37
|
+
// touch target; a mouse keeps the artboard's text-height row.
|
|
38
|
+
link: {
|
|
39
|
+
display: 'inline-flex',
|
|
40
|
+
alignItems: 'center',
|
|
41
|
+
minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
|
|
42
|
+
color: {
|
|
43
|
+
default: colors.accent,
|
|
44
|
+
':hover': colors.accentHover,
|
|
45
|
+
},
|
|
46
|
+
textDecorationLine: {
|
|
47
|
+
default: 'none',
|
|
48
|
+
':hover': 'underline',
|
|
49
|
+
},
|
|
50
|
+
borderRadius: radii.xs,
|
|
51
|
+
transitionProperty: 'color',
|
|
52
|
+
transitionDuration: duration.fast,
|
|
53
|
+
transitionTimingFunction: easing.standard,
|
|
54
|
+
outlineWidth: {
|
|
55
|
+
default: 0,
|
|
56
|
+
':focus-visible': borders.focusRing,
|
|
57
|
+
},
|
|
58
|
+
outlineStyle: {
|
|
59
|
+
default: 'none',
|
|
60
|
+
':focus-visible': 'solid',
|
|
61
|
+
},
|
|
62
|
+
outlineColor: {
|
|
63
|
+
default: 'transparent',
|
|
64
|
+
':focus-visible': colors.accent,
|
|
65
|
+
},
|
|
66
|
+
outlineOffset: {
|
|
67
|
+
default: 0,
|
|
68
|
+
':focus-visible': borders.focusOffset,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
// the artboard keeps the current page in the list's muted colour; it is
|
|
72
|
+
// told apart by not being a link (and by aria-current for assistive tech)
|
|
73
|
+
current: {
|
|
74
|
+
color: 'inherit',
|
|
75
|
+
},
|
|
76
|
+
})
|
|
@@ -5,11 +5,12 @@ export const meta: ComponentMeta = {
|
|
|
5
5
|
whenToUse: [
|
|
6
6
|
'Toolbar or button group that must stay on one row',
|
|
7
7
|
'Horizontal alignment of a known, small number of items',
|
|
8
|
-
'Icon + text pairs,
|
|
8
|
+
'Icon + text pairs, action rows',
|
|
9
9
|
],
|
|
10
10
|
whenNotToUse: [
|
|
11
11
|
'Items that should wrap to the next line — use Cluster',
|
|
12
12
|
'Vertical stacking — use Stack',
|
|
13
|
+
'A breadcrumb trail — use Breadcrumb (a nav landmark with aria-current)',
|
|
13
14
|
],
|
|
14
15
|
relatedTo: [
|
|
15
16
|
{
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Button-styled hyperlink. Renders an <a> tag, not a <button>. Use for navigation, not actions.',
|
|
5
|
+
'Button-styled hyperlink. Renders an <a> tag, not a <button>. Use for navigation, not actions. onNavigate(href, event) hands a plain primary click to a client router (the app calls event.preventDefault(), then navigates); modified clicks keep the browser default.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Navigation that should look like a button (e.g., "Get started", "View all")',
|
|
8
8
|
'Links to external pages with button styling',
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect} from 'storybook/test'
|
|
2
|
+
import {expect, fn} from 'storybook/test'
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
4
|
import {LinkButton} from './LinkButton'
|
|
5
|
+
import {clickLink} from '../../docs/clickLink'
|
|
6
|
+
import {onThemeSurface} from '../../docs/themedSurface'
|
|
7
|
+
import type {OnNavigate} from '../../shared/navigate'
|
|
5
8
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
6
9
|
|
|
7
10
|
const meta: Meta<typeof LinkButton> = {
|
|
@@ -90,3 +93,41 @@ export const AllVariants: Story = {
|
|
|
90
93
|
await expect(links.length).toBe(4)
|
|
91
94
|
},
|
|
92
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Client-side navigation: `onNavigate` gets a plain primary click (the app
|
|
99
|
+
* calls `event.preventDefault()`, then its router's navigate); a modified
|
|
100
|
+
* click, or a link with `target`, keeps the browser default.
|
|
101
|
+
*/
|
|
102
|
+
export const ClientNavigation: StoryObj<{onNavigate: OnNavigate}> = {
|
|
103
|
+
parameters: {a11y: {test: 'error'}},
|
|
104
|
+
decorators: [onThemeSurface],
|
|
105
|
+
args: {onNavigate: fn()},
|
|
106
|
+
render: (args) => (
|
|
107
|
+
<>
|
|
108
|
+
<LinkButton href="#billing" onNavigate={args.onNavigate}>
|
|
109
|
+
Get started
|
|
110
|
+
</LinkButton>{' '}
|
|
111
|
+
<LinkButton href="#help" target="_blank" onNavigate={args.onNavigate}>
|
|
112
|
+
Help
|
|
113
|
+
</LinkButton>
|
|
114
|
+
</>
|
|
115
|
+
),
|
|
116
|
+
play: async ({args, canvas}) => {
|
|
117
|
+
const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
|
|
118
|
+
onNavigate.mockImplementation((_href: string, event: {preventDefault(): void}) =>
|
|
119
|
+
event.preventDefault(),
|
|
120
|
+
)
|
|
121
|
+
const link = canvas.getByRole('link', {name: 'Get started'})
|
|
122
|
+
await expect(clickLink(link).defaultPrevented).toBe(true)
|
|
123
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
124
|
+
await expect(onNavigate.mock.calls[0][0]).toBe('#billing')
|
|
125
|
+
for (const init of [{metaKey: true}, {ctrlKey: true}, {shiftKey: true}, {altKey: true}]) {
|
|
126
|
+
await expect(clickLink(link, init).defaultPrevented).toBe(false)
|
|
127
|
+
}
|
|
128
|
+
// a target opens where it says, not through the router
|
|
129
|
+
const help = canvas.getByRole('link', {name: 'Help'})
|
|
130
|
+
await expect(clickLink(help).defaultPrevented).toBe(false)
|
|
131
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
132
|
+
},
|
|
133
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {ReactNode} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
|
+
import {linkClickHandler, type OnNavigate} from '../../shared/navigate'
|
|
4
5
|
|
|
5
6
|
export type LinkButtonVariant = 'primary' | 'secondary'
|
|
6
7
|
export type LinkButtonSize = 'default' | 'small'
|
|
@@ -12,6 +13,12 @@ interface LinkButtonProps {
|
|
|
12
13
|
fullWidth?: boolean
|
|
13
14
|
target?: '_blank' | '_self'
|
|
14
15
|
rel?: string
|
|
16
|
+
/**
|
|
17
|
+
* Client-side navigation: called for a plain primary click (no modifier
|
|
18
|
+
* key, no `target`); the browser default runs for every other click. Call
|
|
19
|
+
* `event.preventDefault()`, then your router's navigate.
|
|
20
|
+
*/
|
|
21
|
+
onNavigate?: OnNavigate
|
|
15
22
|
children: ReactNode
|
|
16
23
|
}
|
|
17
24
|
|
|
@@ -27,6 +34,7 @@ export function LinkButton({
|
|
|
27
34
|
fullWidth = false,
|
|
28
35
|
target,
|
|
29
36
|
rel,
|
|
37
|
+
onNavigate,
|
|
30
38
|
children,
|
|
31
39
|
}: LinkButtonProps) {
|
|
32
40
|
return (
|
|
@@ -34,6 +42,7 @@ export function LinkButton({
|
|
|
34
42
|
href={href}
|
|
35
43
|
target={target}
|
|
36
44
|
rel={rel}
|
|
45
|
+
onClick={linkClickHandler(href, onNavigate, target)}
|
|
37
46
|
style={[styles.base, sizeMap[size], styles[variant], fullWidth && styles.fullWidth]}
|
|
38
47
|
>
|
|
39
48
|
{children}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'The previous and next pages in reading order, at the foot of a page: a labelled nav with up to two link cards (a direction label over the page title, both from props). A missing side keeps its slot, so Next stays right-aligned; the cards stack below the xs container width. Links take href and an optional onNavigate(href, event) (plain clicks only). Compound component — Root is required.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Docs or a guide read in order — the foot of each page',
|
|
8
|
+
'A sequence of steps where each step is its own page',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Numbered result pages of a table — use Pagination from @duro-app/ui/table',
|
|
12
|
+
'Going up the hierarchy — use Breadcrumb',
|
|
13
|
+
],
|
|
14
|
+
anatomy: {
|
|
15
|
+
required: ['Root'],
|
|
16
|
+
optional: ['Prev', 'Next'],
|
|
17
|
+
},
|
|
18
|
+
relatedTo: [
|
|
19
|
+
{
|
|
20
|
+
component: 'Breadcrumb',
|
|
21
|
+
kind: 'contrast',
|
|
22
|
+
relationship: 'PageNav goes along the reading order; Breadcrumb goes up the hierarchy',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
component: 'Card',
|
|
26
|
+
kind: 'contrast',
|
|
27
|
+
relationship:
|
|
28
|
+
'PageNav draws its own link cards; do not wrap a Card in a link to fake a prev/next pair',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
example: `<PageNav.Root aria-label="Pages">
|
|
32
|
+
<PageNav.Prev href="/docs/architecture/clusters" label="Previous" title="Clusters and bootstrap" onNavigate={go} />
|
|
33
|
+
<PageNav.Next href="/docs/architecture/secrets" label="Next" title="Secrets and Vault" onNavigate={go} />
|
|
34
|
+
</PageNav.Root>`,
|
|
35
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {css, html} from 'react-strict-dom'
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
3
|
+
import {expect, fn} from 'storybook/test'
|
|
4
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
5
|
+
import {PageNav} from './PageNav'
|
|
6
|
+
import {clickLink} from '../../docs/clickLink'
|
|
7
|
+
import {onThemeSurface} from '../../docs/themedSurface'
|
|
8
|
+
import type {OnNavigate} from '../../shared/navigate'
|
|
9
|
+
|
|
10
|
+
const meta: Meta = {
|
|
11
|
+
title: 'Components/PageNav',
|
|
12
|
+
parameters: {a11y: {test: 'error'}},
|
|
13
|
+
decorators: [onThemeSurface],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj
|
|
18
|
+
|
|
19
|
+
const styles = css.create({
|
|
20
|
+
column: {width: sizes.panelLg},
|
|
21
|
+
narrow: {width: sizes.panelSm},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function Pages(props: {prev?: boolean; next?: boolean; onNavigate?: OnNavigate}) {
|
|
25
|
+
const {prev = true, next = true, onNavigate} = props
|
|
26
|
+
return (
|
|
27
|
+
<PageNav.Root aria-label="Pages">
|
|
28
|
+
{prev ? (
|
|
29
|
+
<PageNav.Prev
|
|
30
|
+
href="#clusters"
|
|
31
|
+
label="Previous"
|
|
32
|
+
title="Clusters and bootstrap"
|
|
33
|
+
onNavigate={onNavigate}
|
|
34
|
+
/>
|
|
35
|
+
) : null}
|
|
36
|
+
{next ? (
|
|
37
|
+
<PageNav.Next
|
|
38
|
+
href="#secrets"
|
|
39
|
+
label="Next"
|
|
40
|
+
title="Secrets and Vault"
|
|
41
|
+
onNavigate={onNavigate}
|
|
42
|
+
/>
|
|
43
|
+
) : null}
|
|
44
|
+
</PageNav.Root>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Default: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<html.div style={styles.column}>
|
|
51
|
+
<Pages />
|
|
52
|
+
</html.div>
|
|
53
|
+
),
|
|
54
|
+
play: async ({canvas, userEvent}) => {
|
|
55
|
+
await expect(canvas.getByRole('navigation', {name: 'Pages'})).toBeInTheDocument()
|
|
56
|
+
// the arrows are decorative: the name is the label and the title
|
|
57
|
+
const prev = canvas.getByRole('link', {name: 'Previous Clusters and bootstrap'})
|
|
58
|
+
const next = canvas.getByRole('link', {name: 'Next Secrets and Vault'})
|
|
59
|
+
// side by side, Next on the right
|
|
60
|
+
await expect(next.getBoundingClientRect().left).toBeGreaterThan(
|
|
61
|
+
prev.getBoundingClientRect().right,
|
|
62
|
+
)
|
|
63
|
+
await userEvent.tab()
|
|
64
|
+
await expect(prev).toHaveFocus()
|
|
65
|
+
await userEvent.tab()
|
|
66
|
+
await expect(next).toHaveFocus()
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The first page of a section: no Prev, and Next keeps the right half. */
|
|
71
|
+
export const OnlyNext: Story = {
|
|
72
|
+
render: () => (
|
|
73
|
+
<html.div style={styles.column}>
|
|
74
|
+
<Pages prev={false} />
|
|
75
|
+
</html.div>
|
|
76
|
+
),
|
|
77
|
+
play: async ({canvas}) => {
|
|
78
|
+
const nav = canvas.getByRole('navigation', {name: 'Pages'})
|
|
79
|
+
const next = canvas.getByRole('link', {name: 'Next Secrets and Vault'})
|
|
80
|
+
const navBox = nav.getBoundingClientRect()
|
|
81
|
+
const nextBox = next.getBoundingClientRect()
|
|
82
|
+
await expect(nextBox.left).toBeGreaterThan(navBox.left + navBox.width / 3)
|
|
83
|
+
await expect(Math.round(nextBox.right)).toBe(Math.round(navBox.right))
|
|
84
|
+
await expect(getComputedStyle(next).textAlign).toBe('right')
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The last page of a section: no Next, and Prev keeps the left half. */
|
|
89
|
+
export const OnlyPrev: Story = {
|
|
90
|
+
render: () => (
|
|
91
|
+
<html.div style={styles.column}>
|
|
92
|
+
<Pages next={false} />
|
|
93
|
+
</html.div>
|
|
94
|
+
),
|
|
95
|
+
play: async ({canvas}) => {
|
|
96
|
+
const navBox = canvas.getByRole('navigation', {name: 'Pages'}).getBoundingClientRect()
|
|
97
|
+
const prevBox = canvas
|
|
98
|
+
.getByRole('link', {name: 'Previous Clusters and bootstrap'})
|
|
99
|
+
.getBoundingClientRect()
|
|
100
|
+
await expect(canvas.getAllByRole('link')).toHaveLength(1)
|
|
101
|
+
await expect(canvas.queryByRole('link', {name: /Next/})).toBeNull()
|
|
102
|
+
await expect(Math.round(prevBox.left)).toBe(Math.round(navBox.left))
|
|
103
|
+
await expect(prevBox.right).toBeLessThan(navBox.left + navBox.width / 2)
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Below the xs container width the cards stack, Prev first. */
|
|
108
|
+
export const Stacked: Story = {
|
|
109
|
+
render: () => (
|
|
110
|
+
<html.div style={styles.narrow}>
|
|
111
|
+
<Pages />
|
|
112
|
+
</html.div>
|
|
113
|
+
),
|
|
114
|
+
play: async ({canvas}) => {
|
|
115
|
+
const prev = canvas.getByRole('link', {name: 'Previous Clusters and bootstrap'})
|
|
116
|
+
const next = canvas.getByRole('link', {name: 'Next Secrets and Vault'})
|
|
117
|
+
await expect(next.getBoundingClientRect().top).toBeGreaterThanOrEqual(
|
|
118
|
+
prev.getBoundingClientRect().bottom,
|
|
119
|
+
)
|
|
120
|
+
await expect(Math.round(next.getBoundingClientRect().left)).toBe(
|
|
121
|
+
Math.round(prev.getBoundingClientRect().left),
|
|
122
|
+
)
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const ClientNavigation: StoryObj<{onNavigate: OnNavigate}> = {
|
|
127
|
+
args: {onNavigate: fn()},
|
|
128
|
+
render: (args) => (
|
|
129
|
+
<html.div style={styles.column}>
|
|
130
|
+
<Pages onNavigate={args.onNavigate} />
|
|
131
|
+
</html.div>
|
|
132
|
+
),
|
|
133
|
+
play: async ({args, canvas}) => {
|
|
134
|
+
const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
|
|
135
|
+
onNavigate.mockImplementation((_href: string, event: {preventDefault(): void}) =>
|
|
136
|
+
event.preventDefault(),
|
|
137
|
+
)
|
|
138
|
+
const next = canvas.getByRole('link', {name: 'Next Secrets and Vault'})
|
|
139
|
+
await expect(clickLink(next).defaultPrevented).toBe(true)
|
|
140
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
141
|
+
await expect(onNavigate.mock.calls[0][0]).toBe('#secrets')
|
|
142
|
+
await expect(clickLink(next, {metaKey: true}).defaultPrevented).toBe(false)
|
|
143
|
+
await expect(clickLink(next, {altKey: true}).defaultPrevented).toBe(false)
|
|
144
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
145
|
+
},
|
|
146
|
+
}
|