@duro-app/ui 5.7.0 → 5.8.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-Dj7PXqO_.js → Table-CAjPFgTX.js} +397 -394
- package/dist/Table-CAjPFgTX.js.map +1 -0
- package/dist/{compiler-runtime-B2IcI8_O.js → compiler-runtime-BuugS4D0.js} +67 -59
- package/dist/{compiler-runtime-B2IcI8_O.js.map → compiler-runtime-BuugS4D0.js.map} +1 -1
- package/dist/components/AppShell/AppShell.d.ts +74 -0
- package/dist/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.meta.d.ts +3 -0
- package/dist/components/AppShell/AppShell.meta.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.stories.d.ts +186 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -0
- package/dist/components/AppShell/styles.css.d.ts +187 -0
- package/dist/components/AppShell/styles.css.d.ts.map +1 -0
- package/dist/components/Aside/Aside.d.ts +23 -0
- package/dist/components/Aside/Aside.d.ts.map +1 -0
- package/dist/components/Aside/Aside.meta.d.ts +3 -0
- package/dist/components/Aside/Aside.meta.d.ts.map +1 -0
- package/dist/components/Aside/Aside.stories.d.ts +45 -0
- package/dist/components/Aside/Aside.stories.d.ts.map +1 -0
- package/dist/components/Aside/styles.css.d.ts +44 -0
- package/dist/components/Aside/styles.css.d.ts.map +1 -0
- package/dist/components/Button/styles.css.d.ts +2 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +3 -3
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.d.ts +29 -0
- package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts +3 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts +67 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
- package/dist/components/CodeBlock/styles.css.d.ts +32 -0
- package/dist/components/CodeBlock/styles.css.d.ts.map +1 -0
- package/dist/components/Grid/Grid.d.ts +13 -4
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +28 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Grid/styles.css.d.ts +3 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Heading/Heading.d.ts +3 -1
- package/dist/components/Heading/Heading.d.ts.map +1 -1
- package/dist/components/Heading/Heading.stories.d.ts +9 -0
- package/dist/components/Heading/Heading.stories.d.ts.map +1 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts +7 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +2 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/LiveRegion/LiveRegion.d.ts +28 -0
- package/dist/components/LiveRegion/LiveRegion.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts +3 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts +66 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts.map +1 -0
- package/dist/components/Menu/Menu.stories.d.ts +7 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +2 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/PageNav/styles.css.d.ts +1 -1
- package/dist/components/Prose/Prose.d.ts +17 -0
- package/dist/components/Prose/Prose.d.ts.map +1 -0
- package/dist/components/Prose/Prose.meta.d.ts +3 -0
- package/dist/components/Prose/Prose.meta.d.ts.map +1 -0
- package/dist/components/Prose/Prose.stories.d.ts +63 -0
- package/dist/components/Prose/Prose.stories.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts +7 -1
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.stories.d.ts +3 -0
- package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
- package/dist/components/Time/Time.d.ts +26 -0
- package/dist/components/Time/Time.d.ts.map +1 -0
- package/dist/components/Time/Time.meta.d.ts +3 -0
- package/dist/components/Time/Time.meta.d.ts.map +1 -0
- package/dist/components/Time/Time.stories.d.ts +77 -0
- package/dist/components/Time/Time.stories.d.ts.map +1 -0
- package/dist/components/Time/relative.d.ts +27 -0
- package/dist/components/Time/relative.d.ts.map +1 -0
- package/dist/form.js +1 -1
- package/dist/hooks/useContainerBelow.d.ts +18 -0
- package/dist/hooks/useContainerBelow.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4766 -4088
- package/dist/index.js.map +1 -1
- package/dist/table.js +2 -2
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +62 -0
- package/src/components/AppShell/AppShell.stories.tsx +770 -0
- package/src/components/AppShell/AppShell.tsx +346 -0
- package/src/components/AppShell/styles.css.ts +219 -0
- package/src/components/Aside/Aside.meta.ts +33 -0
- package/src/components/Aside/Aside.stories.tsx +106 -0
- package/src/components/Aside/Aside.tsx +42 -0
- package/src/components/Aside/styles.css.ts +60 -0
- package/src/components/Button/styles.css.ts +7 -2
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +4 -5
- package/src/components/CodeBlock/CodeBlock.meta.ts +29 -0
- package/src/components/CodeBlock/CodeBlock.stories.tsx +162 -0
- package/src/components/CodeBlock/CodeBlock.tsx +99 -0
- package/src/components/CodeBlock/styles.css.ts +36 -0
- package/src/components/Grid/Grid.meta.ts +2 -1
- package/src/components/Grid/Grid.stories.tsx +202 -3
- package/src/components/Grid/Grid.tsx +35 -6
- package/src/components/Grid/styles.css.ts +11 -0
- package/src/components/Heading/Heading.stories.tsx +51 -0
- package/src/components/Heading/Heading.tsx +38 -7
- package/src/components/LinkButton/LinkButton.stories.tsx +41 -1
- package/src/components/LinkButton/styles.css.ts +6 -2
- package/src/components/LiveRegion/LiveRegion.meta.ts +29 -0
- package/src/components/LiveRegion/LiveRegion.stories.tsx +139 -0
- package/src/components/LiveRegion/LiveRegion.tsx +48 -0
- package/src/components/Menu/Menu.stories.tsx +62 -0
- package/src/components/Menu/styles.css.ts +6 -3
- package/src/components/Prose/Prose.meta.ts +32 -0
- package/src/components/Prose/Prose.stories.tsx +233 -0
- package/src/components/Prose/Prose.tsx +25 -0
- package/src/components/Prose/prose.css +177 -0
- package/src/components/SideNav/SideNav.stories.tsx +29 -0
- package/src/components/SideNav/SideNav.tsx +14 -2
- package/src/components/Time/Time.meta.ts +17 -0
- package/src/components/Time/Time.stories.tsx +173 -0
- package/src/components/Time/Time.tsx +33 -0
- package/src/components/Time/relative.ts +89 -0
- package/src/hooks/useContainerBelow.ts +39 -0
- package/src/index.ts +13 -1
- package/src/strict.css +4 -1
- package/dist/Table-Dj7PXqO_.js.map +0 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, waitFor} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
5
|
+
import {SPACING_KEYS, SPACING_PX, type SpacingToken} from '@duro-app/tokens/keys'
|
|
6
|
+
import {Aside} from './Aside'
|
|
7
|
+
import {Grid} from '../Grid/Grid'
|
|
8
|
+
import {Stack} from '../Stack/Stack'
|
|
9
|
+
import {Text} from '../Text/Text'
|
|
10
|
+
import {TextLink} from '../TextLink/TextLink'
|
|
11
|
+
import {onThemeSurface} from '../../docs/themedSurface'
|
|
12
|
+
|
|
13
|
+
interface AsideArgs {
|
|
14
|
+
'aria-label': string
|
|
15
|
+
/** The gap kept between the viewport's top and the Aside while it sticks. */
|
|
16
|
+
offset: SpacingToken
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const meta = {
|
|
20
|
+
title: 'Layout/Aside',
|
|
21
|
+
args: {'aria-label': 'Page outline', offset: 'lg'},
|
|
22
|
+
argTypes: {
|
|
23
|
+
'aria-label': {control: 'text'},
|
|
24
|
+
offset: {control: 'select', options: [...SPACING_KEYS]},
|
|
25
|
+
},
|
|
26
|
+
parameters: {a11y: {test: 'error'}},
|
|
27
|
+
decorators: [onThemeSurface],
|
|
28
|
+
render: (args) => <LongPage {...args} />,
|
|
29
|
+
} satisfies Meta<AsideArgs>
|
|
30
|
+
|
|
31
|
+
export default meta
|
|
32
|
+
type Story = StoryObj<typeof meta>
|
|
33
|
+
|
|
34
|
+
const styles = css.create({
|
|
35
|
+
page: {width: sizes.pageLg},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const PARAGRAPHS = Array.from({length: 80}, (_, i) => `Paragraph ${i + 1} of a long runbook.`)
|
|
39
|
+
const SECTIONS = Array.from({length: 60}, (_, i) => `Section ${i + 1}`)
|
|
40
|
+
|
|
41
|
+
const asideRef: {current: HTMLElement | null} = {current: null}
|
|
42
|
+
|
|
43
|
+
function LongPage({'aria-label': ariaLabel, offset}: AsideArgs) {
|
|
44
|
+
return (
|
|
45
|
+
<html.div style={styles.page}>
|
|
46
|
+
<Grid layout="content-aside" gap="xl">
|
|
47
|
+
<Stack gap="md">
|
|
48
|
+
{PARAGRAPHS.map((text) => (
|
|
49
|
+
<Text key={text}>{text}</Text>
|
|
50
|
+
))}
|
|
51
|
+
</Stack>
|
|
52
|
+
<Aside aria-label={ariaLabel} offset={offset} ref={asideRef}>
|
|
53
|
+
<Stack gap="xs">
|
|
54
|
+
{SECTIONS.map((name) => (
|
|
55
|
+
<TextLink key={name} href={`#${name}`}>
|
|
56
|
+
{name}
|
|
57
|
+
</TextLink>
|
|
58
|
+
))}
|
|
59
|
+
</Stack>
|
|
60
|
+
</Aside>
|
|
61
|
+
</Grid>
|
|
62
|
+
</html.div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A long page with its outline beside it. Scroll the canvas: the Aside sticks
|
|
68
|
+
* `offset` below the viewport's top and scrolls on its own, being taller than
|
|
69
|
+
* the viewport. Change `offset` to move where it sticks.
|
|
70
|
+
*/
|
|
71
|
+
export const Playground: Story = {}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sticks within the viewport while the page scrolls, and scrolls on its own
|
|
75
|
+
* when it is taller than the viewport; a labelled complementary landmark.
|
|
76
|
+
* It scrolls the page, so it runs as a test only.
|
|
77
|
+
*/
|
|
78
|
+
export const StickyAndScrolling: Story = {
|
|
79
|
+
tags: ['!dev', '!autodocs'],
|
|
80
|
+
play: async ({args, canvas}) => {
|
|
81
|
+
const aside = canvas.getByRole('complementary', {name: args['aria-label']})
|
|
82
|
+
await expect(asideRef.current).toBe(aside)
|
|
83
|
+
await expect(aside.tagName).toBe('ASIDE')
|
|
84
|
+
|
|
85
|
+
const cs = getComputedStyle(aside)
|
|
86
|
+
await expect(cs.position).toBe('sticky')
|
|
87
|
+
// outside AppShell there is no bar to clear: the bar's custom property is
|
|
88
|
+
// unset, its fallback is 0, and only the offset remains
|
|
89
|
+
await expect(cs.getPropertyValue('--duro-app-shell-bar')).toBe('')
|
|
90
|
+
await expect(cs.top).toBe(`${SPACING_PX.lg}px`)
|
|
91
|
+
await expect(cs.zIndex).toBe('1')
|
|
92
|
+
// taller than the room it has: it scrolls on its own, inside the viewport
|
|
93
|
+
await expect(aside.clientHeight).toBeLessThanOrEqual(window.innerHeight - 2 * SPACING_PX.lg)
|
|
94
|
+
await expect(aside.scrollHeight).toBeGreaterThan(aside.clientHeight)
|
|
95
|
+
|
|
96
|
+
const scroller = document.scrollingElement as HTMLElement
|
|
97
|
+
const start = scroller.scrollTop
|
|
98
|
+
try {
|
|
99
|
+
scroller.scrollTop = start + 600
|
|
100
|
+
await waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(start))
|
|
101
|
+
await waitFor(() => expect(Math.round(aside.getBoundingClientRect().top)).toBe(SPACING_PX.lg))
|
|
102
|
+
} finally {
|
|
103
|
+
scroller.scrollTop = start
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {ReactNode, Ref} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import type {SpacingToken} from '@duro-app/tokens/keys'
|
|
4
|
+
import {styles} from './styles.css'
|
|
5
|
+
|
|
6
|
+
interface AsideProps {
|
|
7
|
+
/** Names the landmark ("Page outline"); required, from your text catalog. */
|
|
8
|
+
'aria-label': string
|
|
9
|
+
/**
|
|
10
|
+
* The gap kept above the aside while it is stuck, and below it when it is
|
|
11
|
+
* taller than the viewport. Default `lg`.
|
|
12
|
+
*/
|
|
13
|
+
offset?: SpacingToken
|
|
14
|
+
/** The `aside` element. */
|
|
15
|
+
ref?: Ref<HTMLElement>
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const offsetMap = {
|
|
20
|
+
xs: styles.offsetXs,
|
|
21
|
+
sm: styles.offsetSm,
|
|
22
|
+
ms: styles.offsetMs,
|
|
23
|
+
md: styles.offsetMd,
|
|
24
|
+
lg: styles.offsetLg,
|
|
25
|
+
xl: styles.offsetXl,
|
|
26
|
+
xxl: styles.offsetXxl,
|
|
27
|
+
xxxl: styles.offsetXxxl,
|
|
28
|
+
} as const satisfies Record<SpacingToken, unknown>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Aside — content beside the main reading column (a table of contents,
|
|
32
|
+
* related pages): a labelled `aside` landmark that sticks within the viewport
|
|
33
|
+
* while the page scrolls and scrolls on its own when it is taller than the
|
|
34
|
+
* viewport. Put it in the second track of `Grid layout="content-aside"`.
|
|
35
|
+
*/
|
|
36
|
+
export function Aside({'aria-label': ariaLabel, offset = 'lg', ref, children}: AsideProps) {
|
|
37
|
+
return (
|
|
38
|
+
<html.aside ref={ref} aria-label={ariaLabel} style={[styles.root, offsetMap[offset]]}>
|
|
39
|
+
{children}
|
|
40
|
+
</html.aside>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
3
|
+
import {layers} from '@duro-app/tokens/tokens/layers.css'
|
|
4
|
+
|
|
5
|
+
// Kept in step with `--duro-app-shell-bar`, which AppShell/styles.css.ts sets (StyleX needs
|
|
6
|
+
// the literal here, so the name is spelled out twice).
|
|
7
|
+
const BAR = 'var(--duro-app-shell-bar, 0px)'
|
|
8
|
+
|
|
9
|
+
export const styles = css.create({
|
|
10
|
+
// Sticky within the viewport, and its own scroll box when taller than the
|
|
11
|
+
// room it has, so a long table of contents never runs off the screen.
|
|
12
|
+
// alignSelf: as a grid or flex item it would otherwise stretch to the
|
|
13
|
+
// row's height and have nowhere to stick. The raised layer keeps it over
|
|
14
|
+
// content that scrolls under it.
|
|
15
|
+
root: {
|
|
16
|
+
position: 'sticky',
|
|
17
|
+
alignSelf: 'start',
|
|
18
|
+
zIndex: layers.raised,
|
|
19
|
+
overflowY: 'auto',
|
|
20
|
+
overscrollBehavior: 'contain',
|
|
21
|
+
minWidth: 0,
|
|
22
|
+
},
|
|
23
|
+
// The gap above it while stuck, and the same gap kept below it. Inside
|
|
24
|
+
// AppShell below its collapse point the sticky bar covers the top of the
|
|
25
|
+
// viewport, and AppShell sets `--duro-app-shell-bar` to the bar's height (appBarH)
|
|
26
|
+
// so the aside sticks under it; anywhere else the property is unset and
|
|
27
|
+
// the fallback 0 leaves the offset alone.
|
|
28
|
+
offsetXs: {
|
|
29
|
+
top: `calc(${BAR} + ${spacing.xs})`,
|
|
30
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xs})`,
|
|
31
|
+
},
|
|
32
|
+
offsetSm: {
|
|
33
|
+
top: `calc(${BAR} + ${spacing.sm})`,
|
|
34
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.sm})`,
|
|
35
|
+
},
|
|
36
|
+
offsetMs: {
|
|
37
|
+
top: `calc(${BAR} + ${spacing.ms})`,
|
|
38
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.ms})`,
|
|
39
|
+
},
|
|
40
|
+
offsetMd: {
|
|
41
|
+
top: `calc(${BAR} + ${spacing.md})`,
|
|
42
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.md})`,
|
|
43
|
+
},
|
|
44
|
+
offsetLg: {
|
|
45
|
+
top: `calc(${BAR} + ${spacing.lg})`,
|
|
46
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.lg})`,
|
|
47
|
+
},
|
|
48
|
+
offsetXl: {
|
|
49
|
+
top: `calc(${BAR} + ${spacing.xl})`,
|
|
50
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xl})`,
|
|
51
|
+
},
|
|
52
|
+
offsetXxl: {
|
|
53
|
+
top: `calc(${BAR} + ${spacing.xxl})`,
|
|
54
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xxl})`,
|
|
55
|
+
},
|
|
56
|
+
offsetXxxl: {
|
|
57
|
+
top: `calc(${BAR} + ${spacing.xxxl})`,
|
|
58
|
+
maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xxxl})`,
|
|
59
|
+
},
|
|
60
|
+
})
|
|
@@ -25,6 +25,9 @@ export const styles = css.create({
|
|
|
25
25
|
transitionTimingFunction: easing.standard,
|
|
26
26
|
textDecoration: 'none',
|
|
27
27
|
whiteSpace: 'nowrap',
|
|
28
|
+
// A touch screen gets the 44px target, as Toggle and the triggers do; a
|
|
29
|
+
// mouse keeps the compact control (5.6, the person's decision).
|
|
30
|
+
minWidth: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
|
|
28
31
|
// Tactile press feedback. The scale is suppressed under reduced motion (and
|
|
29
32
|
// duration.fast collapses to 0ms there too), so it's inert for those users.
|
|
30
33
|
transform: {
|
|
@@ -56,6 +59,7 @@ export const styles = css.create({
|
|
|
56
59
|
paddingBottom: spacing.sm,
|
|
57
60
|
paddingLeft: spacing.md,
|
|
58
61
|
paddingRight: spacing.md,
|
|
62
|
+
minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
|
|
59
63
|
},
|
|
60
64
|
sizeSmall: {
|
|
61
65
|
paddingTop: spacing.xs,
|
|
@@ -63,8 +67,9 @@ export const styles = css.create({
|
|
|
63
67
|
paddingLeft: spacing.sm,
|
|
64
68
|
paddingRight: spacing.sm,
|
|
65
69
|
fontSize: typography.fontSizeXs,
|
|
66
|
-
// Every small control is at least controlSm tall, whatever it holds (5.6)
|
|
67
|
-
|
|
70
|
+
// Every small control is at least controlSm tall, whatever it holds (5.6),
|
|
71
|
+
// and the touch target under a coarse pointer.
|
|
72
|
+
minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
|
|
68
73
|
},
|
|
69
74
|
primary: {
|
|
70
75
|
backgroundColor: {
|
|
@@ -507,13 +507,14 @@ export const SmallTriggersRow: Story = {
|
|
|
507
507
|
},
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
-
/** The same row under a coarse pointer:
|
|
511
|
-
*
|
|
512
|
-
*
|
|
510
|
+
/** The same row under a coarse pointer: every control reaches touchTarget —
|
|
511
|
+
* Toggle, the three triggers, the ghost Menu, and since 5.6 Button too (the
|
|
512
|
+
* 5.5 addendum had left Button out; the person chose to close it). */
|
|
513
513
|
export const SmallTriggersRowCoarsePointer: Story = {
|
|
514
514
|
render: () => <SmallControlsRow />,
|
|
515
515
|
play: async ({canvas}) => {
|
|
516
516
|
const touch = [
|
|
517
|
+
canvas.getByRole('button', {name: 'Clear'}),
|
|
517
518
|
canvas.getByRole('button', {name: 'Bold'}),
|
|
518
519
|
canvas.getByRole('button', {name: 'Search'}),
|
|
519
520
|
canvas.getByRole('combobox', {name: 'Block type'}),
|
|
@@ -521,12 +522,10 @@ export const SmallTriggersRowCoarsePointer: Story = {
|
|
|
521
522
|
canvas.getByRole('button', {name: 'Text colour'}),
|
|
522
523
|
canvas.getByRole('button', {name: 'More'}),
|
|
523
524
|
]
|
|
524
|
-
const button = canvas.getByRole('button', {name: 'Clear'})
|
|
525
525
|
await withCoarsePointer(async () => {
|
|
526
526
|
for (const el of touch) {
|
|
527
527
|
await waitFor(() => expect(el.offsetHeight).toBe(SIZES_PX.touchTarget))
|
|
528
528
|
}
|
|
529
|
-
await expect(button.offsetHeight).toBe(SIZES_PX.controlSm)
|
|
530
529
|
})
|
|
531
530
|
},
|
|
532
531
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'A block of code with a copy button: a pre that scrolls sideways when a line is long, and a Button that copies its text, shows the copied label for copiedDuration ms and announces it through a polite LiveRegion. Labels come from props.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Code or a command the reader is likely to paste: a docs page, an install step',
|
|
8
|
+
"A Markdown renderer's pre, inside Prose",
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'A short identifier in running text — use inline code (Prose styles it)',
|
|
12
|
+
'Editable code — use Textarea',
|
|
13
|
+
],
|
|
14
|
+
relatedTo: [
|
|
15
|
+
{
|
|
16
|
+
component: 'Prose',
|
|
17
|
+
kind: 'composition',
|
|
18
|
+
relationship: 'CodeBlock keeps its own styles inside Prose',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
component: 'LiveRegion',
|
|
22
|
+
kind: 'composition',
|
|
23
|
+
relationship: 'The copied confirmation is announced through a LiveRegion',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
example: `<CodeBlock copyLabel="Copy" copiedLabel="Copied">
|
|
27
|
+
<code>pnpm add @duro-app/ui</code>
|
|
28
|
+
</CodeBlock>`,
|
|
29
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, fn, spyOn, waitFor} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {CodeBlock} from './CodeBlock'
|
|
5
|
+
import {onThemeSurface} from '../../docs/themedSurface'
|
|
6
|
+
|
|
7
|
+
interface CodeBlockArgs {
|
|
8
|
+
/** The code shown (and copied). */
|
|
9
|
+
code: string
|
|
10
|
+
copyLabel: string
|
|
11
|
+
copiedLabel: string
|
|
12
|
+
/** How long the copied label stays, in ms. */
|
|
13
|
+
copiedDuration: number
|
|
14
|
+
/** A refused clipboard (permission denied, an insecure page). */
|
|
15
|
+
onCopyError: (error: unknown) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COMMAND =
|
|
19
|
+
'flux bootstrap git --url=ssh://git@forge/homelab --branch=main --path=clusters/homelab'
|
|
20
|
+
|
|
21
|
+
const meta = {
|
|
22
|
+
title: 'Components/CodeBlock',
|
|
23
|
+
args: {
|
|
24
|
+
code: COMMAND,
|
|
25
|
+
copyLabel: 'Copy',
|
|
26
|
+
copiedLabel: 'Copied',
|
|
27
|
+
copiedDuration: 2000,
|
|
28
|
+
onCopyError: fn(),
|
|
29
|
+
},
|
|
30
|
+
argTypes: {
|
|
31
|
+
code: {control: 'text'},
|
|
32
|
+
copyLabel: {control: 'text'},
|
|
33
|
+
copiedLabel: {control: 'text'},
|
|
34
|
+
copiedDuration: {control: {type: 'number', min: 0, step: 100}},
|
|
35
|
+
},
|
|
36
|
+
parameters: {a11y: {test: 'error'}},
|
|
37
|
+
decorators: [onThemeSurface],
|
|
38
|
+
render: ({code, ...args}) => (
|
|
39
|
+
<CodeBlock {...args}>
|
|
40
|
+
<html.code>{code}</html.code>
|
|
41
|
+
</CodeBlock>
|
|
42
|
+
),
|
|
43
|
+
} satisfies Meta<CodeBlockArgs>
|
|
44
|
+
|
|
45
|
+
export default meta
|
|
46
|
+
type Story = StoryObj<typeof meta>
|
|
47
|
+
|
|
48
|
+
const TEST_ONLY = ['!dev', '!autodocs']
|
|
49
|
+
|
|
50
|
+
const styles = css.create({
|
|
51
|
+
half: {maxWidth: '50%'},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Press the copy button: the label changes to `copiedLabel` for
|
|
56
|
+
* `copiedDuration` ms and a polite region announces it. A refused clipboard
|
|
57
|
+
* shows up in Actions as `onCopyError`.
|
|
58
|
+
*/
|
|
59
|
+
export const Playground: Story = {}
|
|
60
|
+
|
|
61
|
+
/** Playground's checks, kept off the visible story so a Control change cannot fail them. */
|
|
62
|
+
export const PlaygroundChecks: Story = {
|
|
63
|
+
...Playground,
|
|
64
|
+
tags: TEST_ONLY,
|
|
65
|
+
play: async ({args, canvas}) => {
|
|
66
|
+
await expect(canvas.getByRole('button', {name: args.copyLabel})).toBeVisible()
|
|
67
|
+
await expect(canvas.getByText(args.code)).toBeInTheDocument()
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A long line scrolls inside the block instead of widening the page. */
|
|
72
|
+
export const LongLineScrolls: Story = {
|
|
73
|
+
args: {code: COMMAND.repeat(3)},
|
|
74
|
+
render: ({code, ...args}) => (
|
|
75
|
+
<html.div style={styles.half}>
|
|
76
|
+
<CodeBlock {...args}>
|
|
77
|
+
<html.code>{code}</html.code>
|
|
78
|
+
</CodeBlock>
|
|
79
|
+
</html.div>
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** LongLineScrolls's checks, kept off the visible story so a Control change cannot fail them. */
|
|
84
|
+
export const LongLineScrollsChecks: Story = {
|
|
85
|
+
...LongLineScrolls,
|
|
86
|
+
tags: TEST_ONLY,
|
|
87
|
+
play: async ({canvasElement}) => {
|
|
88
|
+
const pre = canvasElement.querySelector('pre') as HTMLElement
|
|
89
|
+
await expect(pre.scrollWidth).toBeGreaterThan(pre.clientWidth)
|
|
90
|
+
await expect(getComputedStyle(pre).overflowX).toBe('auto')
|
|
91
|
+
// and a keyboard can reach it to scroll it
|
|
92
|
+
await waitFor(() => expect(pre).toHaveAttribute('tabindex', '0'))
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Copies the text, confirms on the button and announces it, then resets. */
|
|
97
|
+
export const CopiesAndAnnounces: Story = {
|
|
98
|
+
tags: TEST_ONLY,
|
|
99
|
+
args: {copiedDuration: 300},
|
|
100
|
+
play: async ({args, canvas, userEvent}) => {
|
|
101
|
+
const write = spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
|
102
|
+
try {
|
|
103
|
+
const status = canvas.getByRole('status')
|
|
104
|
+
await expect(status).toBeEmptyDOMElement()
|
|
105
|
+
await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
|
|
106
|
+
await expect(write).toHaveBeenCalledWith(args.code)
|
|
107
|
+
await waitFor(() =>
|
|
108
|
+
expect(canvas.getByRole('button', {name: args.copiedLabel})).toBeVisible(),
|
|
109
|
+
)
|
|
110
|
+
await expect(status).toHaveTextContent(args.copiedLabel)
|
|
111
|
+
// back to the label after copiedDuration; the region stays mounted
|
|
112
|
+
await waitFor(() => expect(canvas.getByRole('button', {name: args.copyLabel})).toBeVisible())
|
|
113
|
+
await expect(canvas.getByRole('status')).toBe(status)
|
|
114
|
+
await expect(status).toBeEmptyDOMElement()
|
|
115
|
+
} finally {
|
|
116
|
+
write.mockRestore()
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A refused clipboard confirms nothing and reports the error. */
|
|
122
|
+
export const CopyRefused: Story = {
|
|
123
|
+
tags: TEST_ONLY,
|
|
124
|
+
play: async ({args, canvas, userEvent}) => {
|
|
125
|
+
const write = spyOn(navigator.clipboard, 'writeText').mockRejectedValue(
|
|
126
|
+
new DOMException('Denied', 'NotAllowedError'),
|
|
127
|
+
)
|
|
128
|
+
try {
|
|
129
|
+
await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
|
|
130
|
+
await waitFor(() => expect(args.onCopyError).toHaveBeenCalledTimes(1))
|
|
131
|
+
await expect(canvas.getByRole('button', {name: args.copyLabel})).toBeInTheDocument()
|
|
132
|
+
await expect(canvas.getByRole('status')).toBeEmptyDOMElement()
|
|
133
|
+
} finally {
|
|
134
|
+
write.mockRestore()
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Without onCopyError, a refused clipboard still reaches the console. */
|
|
140
|
+
export const CopyRefusedWithoutHandler: Story = {
|
|
141
|
+
tags: TEST_ONLY,
|
|
142
|
+
render: ({code, onCopyError: _onCopyError, ...args}) => (
|
|
143
|
+
<CodeBlock {...args}>
|
|
144
|
+
<html.code>{code}</html.code>
|
|
145
|
+
</CodeBlock>
|
|
146
|
+
),
|
|
147
|
+
play: async ({args, canvas, userEvent}) => {
|
|
148
|
+
const write = spyOn(navigator.clipboard, 'writeText').mockRejectedValue(
|
|
149
|
+
new DOMException('Denied', 'NotAllowedError'),
|
|
150
|
+
)
|
|
151
|
+
const logged = spyOn(console, 'error').mockImplementation(() => {})
|
|
152
|
+
try {
|
|
153
|
+
await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
|
|
154
|
+
await waitFor(() => expect(logged).toHaveBeenCalledTimes(1))
|
|
155
|
+
await expect(String(logged.mock.calls[0]?.[0])).toContain('CodeBlock')
|
|
156
|
+
await expect(canvas.getByRole('status')).toBeEmptyDOMElement()
|
|
157
|
+
} finally {
|
|
158
|
+
logged.mockRestore()
|
|
159
|
+
write.mockRestore()
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {Button} from '../Button/Button'
|
|
4
|
+
import {LiveRegion} from '../LiveRegion/LiveRegion'
|
|
5
|
+
import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
|
|
6
|
+
import {styles} from './styles.css'
|
|
7
|
+
|
|
8
|
+
interface CodeBlockProps {
|
|
9
|
+
/**
|
|
10
|
+
* The code: text, or the `<code>` element a Markdown renderer produced
|
|
11
|
+
* (highlighted spans included). What is copied is its text content.
|
|
12
|
+
*/
|
|
13
|
+
children: ReactNode
|
|
14
|
+
/** The copy button's label ("Copy"), from your text catalog. */
|
|
15
|
+
copyLabel: string
|
|
16
|
+
/**
|
|
17
|
+
* The label while the copy is confirmed ("Copied"), also announced to
|
|
18
|
+
* screen readers.
|
|
19
|
+
*/
|
|
20
|
+
copiedLabel: string
|
|
21
|
+
/** How long the confirmation stays, in milliseconds. Default 2000. */
|
|
22
|
+
copiedDuration?: number
|
|
23
|
+
/** Called when the clipboard refuses the write (an insecure context, a denied permission). */
|
|
24
|
+
onCopyError?: (error: unknown) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* CodeBlock — a block of code with a copy button: a `pre` that scrolls
|
|
29
|
+
* sideways when a line is long (and is then focusable, so a keyboard can
|
|
30
|
+
* scroll it), and a Button that copies its text and
|
|
31
|
+
* confirms ("Copied") on the button and through a polite LiveRegion. Inside
|
|
32
|
+
* `Prose`, pass it as the Markdown renderer's `pre`.
|
|
33
|
+
*/
|
|
34
|
+
export function CodeBlock({
|
|
35
|
+
children,
|
|
36
|
+
copyLabel,
|
|
37
|
+
copiedLabel,
|
|
38
|
+
copiedDuration = 2000,
|
|
39
|
+
onCopyError,
|
|
40
|
+
}: CodeBlockProps) {
|
|
41
|
+
const preRef = useRef<HTMLPreElement | null>(null)
|
|
42
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
43
|
+
const [copied, setCopied] = useState(false)
|
|
44
|
+
// A block that scrolls sideways joins the tab order, so a keyboard can
|
|
45
|
+
// scroll it too; one that fits stays out of it.
|
|
46
|
+
const [scrollable, setScrollable] = useState(false)
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const pre = preRef.current
|
|
50
|
+
if (!pre || typeof ResizeObserver === 'undefined') return
|
|
51
|
+
const measure = () => setScrollable(pre.scrollWidth > pre.clientWidth)
|
|
52
|
+
const observer = new ResizeObserver(measure)
|
|
53
|
+
observer.observe(pre)
|
|
54
|
+
measure()
|
|
55
|
+
return () => observer.disconnect()
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
useEffect(
|
|
59
|
+
() => () => {
|
|
60
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
61
|
+
},
|
|
62
|
+
[],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const copy = useCallback(async () => {
|
|
66
|
+
try {
|
|
67
|
+
await navigator.clipboard.writeText(preRef.current?.textContent ?? '')
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Never dropped: without a handler the failure still reaches the console.
|
|
70
|
+
if (onCopyError) onCopyError(error)
|
|
71
|
+
else console.error('CodeBlock: copying to the clipboard failed', error)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
75
|
+
setCopied(true)
|
|
76
|
+
timerRef.current = setTimeout(() => {
|
|
77
|
+
timerRef.current = null
|
|
78
|
+
setCopied(false)
|
|
79
|
+
}, copiedDuration)
|
|
80
|
+
}, [copiedDuration, onCopyError])
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<html.div style={styles.root}>
|
|
84
|
+
<html.pre
|
|
85
|
+
ref={preRef}
|
|
86
|
+
tabIndex={scrollable ? 0 : undefined}
|
|
87
|
+
style={[typePresets.code, styles.pre]}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</html.pre>
|
|
91
|
+
<html.div style={styles.copy}>
|
|
92
|
+
<Button variant="secondary" size="small" onClick={() => void copy()}>
|
|
93
|
+
{copied ? copiedLabel : copyLabel}
|
|
94
|
+
</Button>
|
|
95
|
+
</html.div>
|
|
96
|
+
<LiveRegion visuallyHidden>{copied ? copiedLabel : ''}</LiveRegion>
|
|
97
|
+
</html.div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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 {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
5
|
+
|
|
6
|
+
export const styles = css.create({
|
|
7
|
+
// Anchors the copy button to the block's top-right corner.
|
|
8
|
+
root: {
|
|
9
|
+
position: 'relative',
|
|
10
|
+
minWidth: 0,
|
|
11
|
+
},
|
|
12
|
+
pre: {
|
|
13
|
+
margin: 0,
|
|
14
|
+
paddingTop: spacing.md,
|
|
15
|
+
paddingBottom: spacing.md,
|
|
16
|
+
paddingLeft: spacing.md,
|
|
17
|
+
paddingRight: spacing.md,
|
|
18
|
+
overflowX: 'auto',
|
|
19
|
+
color: colors.text,
|
|
20
|
+
backgroundColor: colors.bgCard,
|
|
21
|
+
borderWidth: borders.hairline,
|
|
22
|
+
borderStyle: 'solid',
|
|
23
|
+
borderColor: colors.border,
|
|
24
|
+
borderRadius: radii.md,
|
|
25
|
+
whiteSpace: 'pre',
|
|
26
|
+
outlineWidth: {default: 0, ':focus-visible': borders.focusRing},
|
|
27
|
+
outlineStyle: {default: 'none', ':focus-visible': 'solid'},
|
|
28
|
+
outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
|
|
29
|
+
outlineOffset: {default: 0, ':focus-visible': borders.focusOffset},
|
|
30
|
+
},
|
|
31
|
+
copy: {
|
|
32
|
+
position: 'absolute',
|
|
33
|
+
top: spacing.sm,
|
|
34
|
+
right: spacing.sm,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -2,11 +2,12 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
"Grid layout. Columns are a count (1-6), a list of weights ([1, 2] for one-third / two-thirds), responsive auto-fit via minColumnWidth, or a named split layout (list/detail, nav/content) that collapses to one column on the width of its own container, so it is safe to nest and to place beside a DetailPanel. Weights rather than CSS template strings, so the same props render as CSS grid on web and as a wrapping flex row on native.
|
|
5
|
+
"Grid layout. Columns are a count (1-6), a list of weights ([1, 2] for one-third / two-thirds), responsive auto-fit via minColumnWidth, or a named split layout (list/detail, nav/content) that collapses to one column on the width of its own container, so it is safe to nest and to place beside a DetailPanel. Weights rather than CSS template strings, so the same props render as CSS grid on web and as a wrapping flex row on native. layout=\"content-aside\" is a reading column beside an Aside (asideW), one column below md on its own container. ref resolves to the outer element (with a layout, the container its query measures — hand it to useContainerBelow). For another fixed track beside fractions, tracks takes a token track list — tracks={['1fr', 'gridColXs']} — web only, equal columns on native.",
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Card grids, dashboard layouts, multi-column forms',
|
|
8
8
|
'Responsive layouts that should auto-adjust column count',
|
|
9
9
|
'A list/detail or nav/content screen — layout="split" | "split-wide", never a hand-rolled gridTemplateColumns with its own @media; a split inside a split is fine, each collapses on its own room',
|
|
10
|
+
'A long page with a table of contents beside it — layout="content-aside" with an Aside',
|
|
10
11
|
],
|
|
11
12
|
whenNotToUse: [
|
|
12
13
|
'Single-column vertical layout — use Stack',
|