@duro-app/ui 5.5.0 → 5.7.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-DYN-bp0e.js → Table-Dj7PXqO_.js} +405 -387
- package/dist/Table-Dj7PXqO_.js.map +1 -0
- package/dist/components/Button/Button.d.ts +2 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/styles.css.d.ts +1 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.d.ts +95 -14
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
- package/dist/components/DragDrop/styles.css.d.ts +80 -3
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +1 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts +4 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +14 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Meter/Meter.d.ts +17 -0
- package/dist/components/Meter/Meter.d.ts.map +1 -0
- package/dist/components/Meter/Meter.meta.d.ts +3 -0
- package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
- package/dist/components/Meter/Meter.stories.d.ts +12 -0
- package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
- package/dist/components/Meter/styles.css.d.ts +34 -0
- package/dist/components/Meter/styles.css.d.ts.map +1 -0
- package/dist/components/Popover/Popover.d.ts +14 -2
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
- package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
- package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
- package/dist/components/Popover/styles.css.d.ts +15 -0
- package/dist/components/Popover/styles.css.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +4 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +9 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Timeline/Timeline.d.ts +71 -0
- package/dist/components/Timeline/Timeline.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
- package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
- package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
- package/dist/components/Timeline/styles.css.d.ts +158 -0
- package/dist/components/Timeline/styles.css.d.ts.map +1 -0
- package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
- package/dist/components/Toast/Toast.stories.d.ts +9 -0
- package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
- package/dist/components/Toast/ToastProvider.d.ts +11 -5
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/index.d.ts +1 -1
- package/dist/components/Toast/index.d.ts.map +1 -1
- package/dist/components/Toast/styles.css.d.ts +6 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts +2 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +1 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4992 -3845
- package/dist/index.js.map +1 -1
- package/dist/shared/ModalContext.d.ts +7 -0
- package/dist/shared/ModalContext.d.ts.map +1 -1
- package/dist/shared/exports.typecheck.d.ts +9 -0
- package/dist/shared/exports.typecheck.d.ts.map +1 -0
- package/dist/shared/types.d.ts +2 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Button/Button.tsx +2 -1
- package/src/components/Button/styles.css.ts +3 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
- package/src/components/DragDrop/DragDrop.meta.ts +22 -21
- package/src/components/DragDrop/DragDrop.tsx +1147 -246
- package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
- package/src/components/DragDrop/styles.css.ts +74 -4
- package/src/components/LinkButton/styles.css.ts +4 -0
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.tsx +17 -2
- package/src/components/Menu/styles.css.ts +18 -0
- package/src/components/Meter/Meter.meta.ts +24 -0
- package/src/components/Meter/Meter.stories.tsx +64 -0
- package/src/components/Meter/Meter.tsx +55 -0
- package/src/components/Meter/styles.css.ts +30 -0
- package/src/components/Popover/Popover.meta.ts +2 -1
- package/src/components/Popover/Popover.tsx +29 -4
- package/src/components/Popover/PopoverLayerContext.ts +3 -0
- package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
- package/src/components/Popover/styles.css.ts +21 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.tsx +5 -1
- package/src/components/Select/styles.css.ts +12 -0
- package/src/components/Timeline/Timeline.meta.ts +46 -0
- package/src/components/Timeline/Timeline.stories.tsx +238 -0
- package/src/components/Timeline/Timeline.tsx +332 -0
- package/src/components/Timeline/styles.css.ts +162 -0
- package/src/components/Toast/Toast.meta.ts +2 -1
- package/src/components/Toast/Toast.stories.tsx +84 -0
- package/src/components/Toast/ToastProvider.tsx +49 -11
- package/src/components/Toast/index.ts +7 -1
- package/src/components/Toast/styles.css.ts +2 -0
- package/src/components/Toggle/Toggle.tsx +2 -1
- package/src/components/Toggle/styles.css.ts +2 -0
- package/src/index.ts +18 -0
- package/src/shared/ModalContext.ts +8 -1
- package/src/shared/exports.typecheck.ts +16 -0
- package/src/shared/types.ts +3 -0
- package/dist/Table-DYN-bp0e.js.map +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {css} from 'react-strict-dom'
|
|
2
2
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
-
import {radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
3
|
+
import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
4
|
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
5
5
|
import {shadows} from '@duro-app/tokens/tokens/shadows.css'
|
|
6
6
|
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
7
7
|
import {layers} from '@duro-app/tokens/tokens/layers.css'
|
|
8
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
8
9
|
|
|
9
10
|
export const styles = css.create({
|
|
10
11
|
// The item is the drag handle: no browser panning starts on it, so a touch
|
|
@@ -26,12 +27,45 @@ export const styles = css.create({
|
|
|
26
27
|
opacity: 0.4,
|
|
27
28
|
cursor: 'grabbing',
|
|
28
29
|
},
|
|
30
|
+
// The keyboard path: one focusable widget per item.
|
|
31
|
+
handle: {
|
|
32
|
+
borderRadius: radii.sm,
|
|
33
|
+
outlineStyle: {default: 'none', ':focus-visible': 'solid'},
|
|
34
|
+
outlineWidth: borders.focusRing,
|
|
35
|
+
outlineColor: colors.accent,
|
|
36
|
+
outlineOffset: borders.focusOffset,
|
|
37
|
+
},
|
|
38
|
+
// Picked up by keyboard: the ring stays on even without :focus-visible.
|
|
39
|
+
handlePicked: {
|
|
40
|
+
outlineStyle: 'dashed',
|
|
41
|
+
},
|
|
29
42
|
zone: {
|
|
30
43
|
borderRadius: radii.sm,
|
|
31
44
|
transitionProperty: 'box-shadow, background-color',
|
|
32
45
|
transitionDuration: duration.fast,
|
|
33
46
|
transitionTimingFunction: easing.standard,
|
|
34
47
|
},
|
|
48
|
+
listVertical: {
|
|
49
|
+
display: 'flex',
|
|
50
|
+
flexDirection: 'column',
|
|
51
|
+
},
|
|
52
|
+
listHorizontal: {
|
|
53
|
+
display: 'flex',
|
|
54
|
+
flexDirection: 'row',
|
|
55
|
+
},
|
|
56
|
+
gapXs: {gap: spacing.xs},
|
|
57
|
+
gapSm: {gap: spacing.sm},
|
|
58
|
+
gapMs: {gap: spacing.ms},
|
|
59
|
+
gapMd: {gap: spacing.md},
|
|
60
|
+
gapLg: {gap: spacing.lg},
|
|
61
|
+
gapXl: {gap: spacing.xl},
|
|
62
|
+
gapXxl: {gap: spacing.xxl},
|
|
63
|
+
gapXxxl: {gap: spacing.xxxl},
|
|
64
|
+
fill: {
|
|
65
|
+
boxSizing: 'border-box',
|
|
66
|
+
width: '100%',
|
|
67
|
+
height: '100%',
|
|
68
|
+
},
|
|
35
69
|
// Every zone shows it can receive while something is in the air; the one
|
|
36
70
|
// under the pointer lights up. Drop-target rings, not focus: keyboard focus
|
|
37
71
|
// stays on outlines, which survive forced-colors mode.
|
|
@@ -52,6 +86,18 @@ export const styles = css.create({
|
|
|
52
86
|
outlineColor: 'transparent',
|
|
53
87
|
outlineOffset: `calc(-1 * ${borders.strong})`,
|
|
54
88
|
},
|
|
89
|
+
// Refused with a reason: the zone says no in the error tone, the ghost
|
|
90
|
+
// carries the reason.
|
|
91
|
+
zoneRefused: {
|
|
92
|
+
backgroundColor: colors.errorBg,
|
|
93
|
+
outlineStyle: 'dashed',
|
|
94
|
+
outlineWidth: borders.strong,
|
|
95
|
+
outlineColor: colors.errorBorder,
|
|
96
|
+
outlineOffset: `calc(-1 * ${borders.strong})`,
|
|
97
|
+
},
|
|
98
|
+
placeholder: {
|
|
99
|
+
flexShrink: 0,
|
|
100
|
+
},
|
|
55
101
|
ghost: {
|
|
56
102
|
position: 'fixed',
|
|
57
103
|
top: 0,
|
|
@@ -63,9 +109,33 @@ export const styles = css.create({
|
|
|
63
109
|
opacity: 0.9,
|
|
64
110
|
cursor: 'grabbing',
|
|
65
111
|
},
|
|
66
|
-
|
|
112
|
+
// The lifted look: a slight tilt, dropped under reduced motion.
|
|
113
|
+
ghostTilt: {
|
|
114
|
+
transform: {
|
|
115
|
+
default: 'rotate(2deg)',
|
|
116
|
+
'@media (prefers-reduced-motion: reduce)': 'none',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
refusal: {
|
|
120
|
+
marginTop: spacing.xs,
|
|
121
|
+
paddingTop: spacing.xs,
|
|
122
|
+
paddingBottom: spacing.xs,
|
|
123
|
+
paddingLeft: spacing.sm,
|
|
124
|
+
paddingRight: spacing.sm,
|
|
125
|
+
borderRadius: radii.xs,
|
|
126
|
+
borderWidth: borders.hairline,
|
|
127
|
+
borderStyle: 'solid',
|
|
128
|
+
borderColor: colors.errorBorder,
|
|
129
|
+
backgroundColor: colors.bgCard,
|
|
130
|
+
color: colors.errorText,
|
|
131
|
+
fontSize: typography.fontSizeXs,
|
|
132
|
+
lineHeight: typography.lineHeight,
|
|
133
|
+
boxShadow: shadows.md,
|
|
134
|
+
width: 'max-content',
|
|
135
|
+
maxWidth: '100%',
|
|
136
|
+
},
|
|
137
|
+
ghostSize: (width: number, height: number) => ({
|
|
67
138
|
width,
|
|
68
|
-
height,
|
|
69
|
-
transform: `translate3d(${x}px, ${y}px, 0)`,
|
|
139
|
+
minHeight: height,
|
|
70
140
|
}),
|
|
71
141
|
})
|
|
@@ -4,6 +4,7 @@ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
|
4
4
|
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
5
|
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
6
6
|
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
7
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
7
8
|
|
|
8
9
|
export const styles = css.create({
|
|
9
10
|
base: {
|
|
@@ -34,6 +35,9 @@ export const styles = css.create({
|
|
|
34
35
|
paddingLeft: spacing.sm,
|
|
35
36
|
paddingRight: spacing.sm,
|
|
36
37
|
fontSize: typography.fontSizeXs,
|
|
38
|
+
|
|
39
|
+
// Matches Button: every small control is at least controlSm tall (5.6).
|
|
40
|
+
minHeight: sizes.controlSm,
|
|
37
41
|
},
|
|
38
42
|
primary: {
|
|
39
43
|
backgroundColor: {
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars. The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog.',
|
|
5
|
+
'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars; size="small" matches small Toggles and Buttons (a small ghost trigger is iconButtonSm). The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Context menu or "more actions" dropdown',
|
|
8
8
|
'Navigation links in a dropdown',
|
|
@@ -15,6 +15,7 @@ import {styles} from './styles.css'
|
|
|
15
15
|
import {MenuContext, useMenu} from './MenuContext'
|
|
16
16
|
import {useMenuRoot} from './useMenuRoot'
|
|
17
17
|
import {devWarnOnce} from '../../shared/devWarnOnce'
|
|
18
|
+
import type {ControlSize} from '../../shared/types'
|
|
18
19
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
19
20
|
import {computePopoverPosition} from '../Popover/position'
|
|
20
21
|
import {usePopoverLayer} from '../Popover/PopoverLayerContext'
|
|
@@ -58,10 +59,18 @@ interface TriggerProps {
|
|
|
58
59
|
'aria-label'?: string
|
|
59
60
|
/** `ghost`: no border or fill until hover, for toolbars. Default: `default`. */
|
|
60
61
|
variant?: MenuTriggerVariant
|
|
62
|
+
/** `small` matches a small Toggle or Button beside it; a small ghost trigger is `iconButtonSm`. Default: `default`. */
|
|
63
|
+
size?: ControlSize
|
|
61
64
|
ref?: Ref<HTMLButtonElement>
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
function Trigger({
|
|
67
|
+
function Trigger({
|
|
68
|
+
children,
|
|
69
|
+
'aria-label': ariaLabel,
|
|
70
|
+
variant = 'default',
|
|
71
|
+
size = 'default',
|
|
72
|
+
ref,
|
|
73
|
+
}: TriggerProps) {
|
|
65
74
|
const {open, toggle, menuId, triggerId, triggerRef} = useMenu()
|
|
66
75
|
const grouped = useGroupedControl<HTMLButtonElement>()
|
|
67
76
|
const groupedRef = grouped.ref
|
|
@@ -99,7 +108,13 @@ function Trigger({children, 'aria-label': ariaLabel, variant = 'default', ref}:
|
|
|
99
108
|
aria-expanded={open}
|
|
100
109
|
aria-haspopup="menu"
|
|
101
110
|
aria-controls={open ? menuId : undefined}
|
|
102
|
-
style={[
|
|
111
|
+
style={[
|
|
112
|
+
styles.trigger,
|
|
113
|
+
variant === 'ghost'
|
|
114
|
+
? [styles.triggerGhost, size === 'small' && styles.triggerGhostSmall]
|
|
115
|
+
: size === 'small' && styles.triggerSmall,
|
|
116
|
+
grouped.style,
|
|
117
|
+
]}
|
|
103
118
|
>
|
|
104
119
|
{children}
|
|
105
120
|
</html.button>
|
|
@@ -47,6 +47,24 @@ export const styles = css.create({
|
|
|
47
47
|
paddingRight: spacing.xs,
|
|
48
48
|
borderColor: 'transparent',
|
|
49
49
|
},
|
|
50
|
+
// size="small": Toggle sizeSmall's values, as longhands so they win over the
|
|
51
|
+
// trigger's own longhands. Every small control is at least controlSm tall,
|
|
52
|
+
// whatever it holds, and the touch target under a coarse pointer.
|
|
53
|
+
triggerSmall: {
|
|
54
|
+
gap: spacing.xs,
|
|
55
|
+
paddingTop: spacing.xs,
|
|
56
|
+
paddingBottom: spacing.xs,
|
|
57
|
+
paddingLeft: spacing.sm,
|
|
58
|
+
paddingRight: spacing.sm,
|
|
59
|
+
fontSize: typography.fontSizeXs,
|
|
60
|
+
minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
|
|
61
|
+
},
|
|
62
|
+
// A small ghost trigger keeps the ghost's xs padding at the small icon-button size.
|
|
63
|
+
triggerGhostSmall: {
|
|
64
|
+
fontSize: typography.fontSizeXs,
|
|
65
|
+
minWidth: sizes.iconButtonSm,
|
|
66
|
+
minHeight: {default: sizes.iconButtonSm, '@media (pointer: coarse)': sizes.touchTarget},
|
|
67
|
+
},
|
|
50
68
|
popup: {
|
|
51
69
|
position: 'fixed',
|
|
52
70
|
top: 0,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'A scalar inside a known range — WIP 2 of 3, a milestone 40% done — as role="meter" with a value text, drawn as a thin bar (the meterH token). The label is the accessible name; valueText says the value in words.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'A level against a limit or a total: WIP against its limit, tasks done in a milestone, quota used',
|
|
8
|
+
'Beside a count, when the proportion matters more than the number',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Progress of a task that is still running — use Spinner (or a determinate progress pattern)',
|
|
12
|
+
'Picking a value — use an input; a meter has no thumb',
|
|
13
|
+
'A status with no range (ok/failing) — use Badge or StatusIcon',
|
|
14
|
+
],
|
|
15
|
+
relatedTo: [
|
|
16
|
+
{
|
|
17
|
+
component: 'Badge',
|
|
18
|
+
kind: 'contrast',
|
|
19
|
+
relationship: 'Badge states a status or a count; Meter shows a level within a range',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
example: `<Meter label="In progress WIP" value={2} max={3} valueText="2 of 3" />
|
|
23
|
+
<Meter label="Milestone done" value={0.4} tone="success" />`,
|
|
24
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {Meter} from './Meter'
|
|
5
|
+
import {Text} from '../Text/Text'
|
|
6
|
+
import {onThemeSurface} from '../../docs/themedSurface'
|
|
7
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
8
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
9
|
+
|
|
10
|
+
const s = css.create({
|
|
11
|
+
col: {display: 'flex', flexDirection: 'column', gap: spacing.md, maxWidth: sizes.gridColMd},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof Meter> = {
|
|
15
|
+
title: 'Components/Meter',
|
|
16
|
+
component: Meter,
|
|
17
|
+
parameters: {a11y: {test: 'error'}},
|
|
18
|
+
decorators: [onThemeSurface],
|
|
19
|
+
}
|
|
20
|
+
export default meta
|
|
21
|
+
type Story = StoryObj<typeof Meter>
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: {label: 'Milestone done', value: 0.4},
|
|
25
|
+
play: async ({canvas}) => {
|
|
26
|
+
const meter = canvas.getByRole('meter', {name: 'Milestone done'})
|
|
27
|
+
await expect(meter).toHaveAttribute('aria-valuenow', '0.4')
|
|
28
|
+
await expect(meter).toHaveAttribute('aria-valuemin', '0')
|
|
29
|
+
await expect(meter).toHaveAttribute('aria-valuemax', '1')
|
|
30
|
+
await expect(meter).toHaveAttribute('aria-valuetext', '40%')
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Tones for a level against a limit: under, at, over. */
|
|
35
|
+
export const Tones: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<html.div style={s.col}>
|
|
38
|
+
<Text variant="label">Next · 1 of 3</Text>
|
|
39
|
+
<Meter label="Next WIP" value={1} max={3} valueText="1 of 3" />
|
|
40
|
+
<Text variant="label">In progress · 3 of 3</Text>
|
|
41
|
+
<Meter label="In progress WIP" value={3} max={3} valueText="3 of 3" tone="warning" />
|
|
42
|
+
<Text variant="label">Review · 4 of 3</Text>
|
|
43
|
+
<Meter label="Review WIP" value={4} max={3} valueText="4 of 3, over the limit" tone="error" />
|
|
44
|
+
<Text variant="label">Milestone · 70% done</Text>
|
|
45
|
+
<Meter label="Milestone done" value={0.7} tone="success" />
|
|
46
|
+
</html.div>
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Over the maximum, aria-valuenow stays in range and the text carries the
|
|
51
|
+
* true value; the bar fills, no further. */
|
|
52
|
+
export const OverTheLimit: Story = {
|
|
53
|
+
args: {label: 'Review WIP', value: 4, max: 3, valueText: '4 of 3, over the limit', tone: 'error'},
|
|
54
|
+
play: async ({canvas}) => {
|
|
55
|
+
const meter = canvas.getByRole('meter', {name: 'Review WIP'})
|
|
56
|
+
await expect(meter).toHaveAttribute('aria-valuenow', '3')
|
|
57
|
+
await expect(meter).toHaveAttribute('aria-valuetext', '4 of 3, over the limit')
|
|
58
|
+
const fill = meter.firstElementChild as HTMLElement
|
|
59
|
+
await expect(fill.getBoundingClientRect().width).toBeCloseTo(
|
|
60
|
+
meter.getBoundingClientRect().width,
|
|
61
|
+
0,
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {html} from 'react-strict-dom'
|
|
2
|
+
import {styles} from './styles.css'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Meter — a scalar inside a known range (WIP 2 of 3, 40% of a milestone's
|
|
6
|
+
// tasks done): role="meter" with a value text, drawn as a thin bar of the
|
|
7
|
+
// `meterH` token. Not a progress bar for a running task (use Spinner) and
|
|
8
|
+
// not an input (no thumb).
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export type MeterTone = 'accent' | 'success' | 'warning' | 'error'
|
|
12
|
+
|
|
13
|
+
export interface MeterProps {
|
|
14
|
+
value: number
|
|
15
|
+
/** Defaults to 0. */
|
|
16
|
+
min?: number
|
|
17
|
+
/** Defaults to 1, so a fraction needs nothing else. */
|
|
18
|
+
max?: number
|
|
19
|
+
/** The accessible name, e.g. "In progress WIP". */
|
|
20
|
+
label: string
|
|
21
|
+
/** What screen readers read for the value, e.g. "4 of 3, over the limit".
|
|
22
|
+
* Defaults to the percentage. */
|
|
23
|
+
valueText?: string
|
|
24
|
+
/** The fill's colour; defaults to accent. */
|
|
25
|
+
tone?: MeterTone
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const toneStyle = {
|
|
29
|
+
accent: styles.accent,
|
|
30
|
+
success: styles.success,
|
|
31
|
+
warning: styles.warning,
|
|
32
|
+
error: styles.error,
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
export function Meter({value, min = 0, max = 1, label, valueText, tone = 'accent'}: MeterProps) {
|
|
36
|
+
const span = max - min
|
|
37
|
+
const fraction = span > 0 ? (value - min) / span : 0
|
|
38
|
+
const clamped = Math.min(Math.max(fraction, 0), 1)
|
|
39
|
+
// aria-valuenow must sit inside the range; the true value (over the
|
|
40
|
+
// limit, say) is what valueText is for.
|
|
41
|
+
const now = Math.min(Math.max(value, min), max)
|
|
42
|
+
return (
|
|
43
|
+
<html.div
|
|
44
|
+
role="meter"
|
|
45
|
+
aria-label={label}
|
|
46
|
+
aria-valuemin={min}
|
|
47
|
+
aria-valuemax={max}
|
|
48
|
+
aria-valuenow={now}
|
|
49
|
+
aria-valuetext={valueText ?? `${Math.round(clamped * 100)}%`}
|
|
50
|
+
style={styles.track}
|
|
51
|
+
>
|
|
52
|
+
<html.div style={[styles.fill, toneStyle[tone], styles.fillAt(clamped * 100)]} />
|
|
53
|
+
</html.div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
5
|
+
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
6
|
+
|
|
7
|
+
export const styles = css.create({
|
|
8
|
+
track: {
|
|
9
|
+
position: 'relative',
|
|
10
|
+
width: '100%',
|
|
11
|
+
height: sizes.meterH,
|
|
12
|
+
borderRadius: radii.full,
|
|
13
|
+
backgroundColor: colors.border,
|
|
14
|
+
overflow: 'hidden',
|
|
15
|
+
},
|
|
16
|
+
fill: {
|
|
17
|
+
height: '100%',
|
|
18
|
+
borderRadius: radii.full,
|
|
19
|
+
transitionProperty: 'width',
|
|
20
|
+
transitionDuration: duration.base,
|
|
21
|
+
transitionTimingFunction: easing.standard,
|
|
22
|
+
},
|
|
23
|
+
accent: {backgroundColor: colors.accent},
|
|
24
|
+
success: {backgroundColor: colors.success},
|
|
25
|
+
warning: {backgroundColor: colors.warning},
|
|
26
|
+
error: {backgroundColor: colors.error},
|
|
27
|
+
fillAt: (percent: number) => ({
|
|
28
|
+
width: `${percent}%`,
|
|
29
|
+
}),
|
|
30
|
+
})
|
|
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves.',
|
|
5
|
+
'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves. Popover.Trigger takes size="small" beside small Toggles. Popover.Popup raised draws it over another floating surface (floatingRaised, popoverRaised in a modal), never over a modal.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'A small form or controls tied to one element that stay open while you work in them',
|
|
8
8
|
'Anchoring to a spot that is not a button (a canvas point, a text selection) with a virtual anchor',
|
|
9
|
+
'One floating surface over another (a link editor over a selection format bar) — Popover.Popup raised',
|
|
9
10
|
'Holding a Select or Combobox: their portalled list counts as inside the popover, so choosing an item keeps it open; Escape closes an open Select, Combobox or Menu before the popover',
|
|
10
11
|
],
|
|
11
12
|
whenNotToUse: [
|
|
@@ -22,6 +22,7 @@ import {PopoverLayerContext, type PopoverLayerContextValue} from './PopoverLayer
|
|
|
22
22
|
import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
|
|
23
23
|
import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
|
|
24
24
|
import {mergeRefs} from '../../shared/mergeRefs'
|
|
25
|
+
import type {ControlSize} from '../../shared/types'
|
|
25
26
|
import {useInModal} from '../../shared/ModalContext'
|
|
26
27
|
|
|
27
28
|
// --- Context ---
|
|
@@ -166,6 +167,8 @@ function Root({
|
|
|
166
167
|
unregisterParent?.()
|
|
167
168
|
}
|
|
168
169
|
},
|
|
170
|
+
// Inside a raised Popup, this Popover is raised too.
|
|
171
|
+
raised: parentLayer?.raised,
|
|
169
172
|
}),
|
|
170
173
|
[parentLayer],
|
|
171
174
|
)
|
|
@@ -201,11 +204,13 @@ interface TriggerProps {
|
|
|
201
204
|
children: ReactNode
|
|
202
205
|
/** Accessible name: required when the label is an icon only. */
|
|
203
206
|
'aria-label'?: string
|
|
207
|
+
/** `small` matches a small Toggle or Button beside it. Default: `default`. */
|
|
208
|
+
size?: ControlSize
|
|
204
209
|
/** The trigger <button>, e.g. to measure it or return focus to it. */
|
|
205
210
|
ref?: Ref<HTMLButtonElement>
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
function Trigger({children, 'aria-label': ariaLabel, ref}: TriggerProps) {
|
|
213
|
+
function Trigger({children, 'aria-label': ariaLabel, size = 'default', ref}: TriggerProps) {
|
|
209
214
|
const {open, setOpen, popoverId, triggerRef} = usePopover('Trigger')
|
|
210
215
|
const grouped = useGroupedControl<HTMLButtonElement>()
|
|
211
216
|
|
|
@@ -220,7 +225,7 @@ function Trigger({children, 'aria-label': ariaLabel, ref}: TriggerProps) {
|
|
|
220
225
|
aria-haspopup="dialog"
|
|
221
226
|
aria-expanded={open}
|
|
222
227
|
aria-controls={open ? popoverId : undefined}
|
|
223
|
-
style={[styles.trigger, grouped.style]}
|
|
228
|
+
style={[styles.trigger, size === 'small' && styles.triggerSmall, grouped.style]}
|
|
224
229
|
>
|
|
225
230
|
{children}
|
|
226
231
|
</html.button>
|
|
@@ -233,9 +238,18 @@ interface PopupProps {
|
|
|
233
238
|
children: ReactNode
|
|
234
239
|
/** Accessible name for the dialog. */
|
|
235
240
|
label: string
|
|
241
|
+
/**
|
|
242
|
+
* Draw over another floating surface: `layers.floatingRaised` outside a
|
|
243
|
+
* modal, `layers.popoverRaised` inside a Dialog or Drawer, instead of
|
|
244
|
+
* `floating` / `popover`. For one floating thing that must cover a sibling
|
|
245
|
+
* one (a link editor over a format bar), never for covering a modal: a
|
|
246
|
+
* Dialog opened later still covers it. A Popover nested inside a raised one
|
|
247
|
+
* is raised too. Default: inherited, else `false`.
|
|
248
|
+
*/
|
|
249
|
+
raised?: boolean
|
|
236
250
|
}
|
|
237
251
|
|
|
238
|
-
function Popup({children, label}: PopupProps) {
|
|
252
|
+
function Popup({children, label, raised}: PopupProps) {
|
|
239
253
|
const {open, popoverId, side, align, anchor, triggerRef, popupRef, repositionRef} =
|
|
240
254
|
usePopover('Popup')
|
|
241
255
|
const [coords, setCoords] = useState({top: 0, left: 0})
|
|
@@ -243,6 +257,14 @@ function Popup({children, label}: PopupProps) {
|
|
|
243
257
|
// tokens cascade into the popup and it stacks above dialogs.
|
|
244
258
|
const mount = usePortalMount()
|
|
245
259
|
const inModal = useInModal()
|
|
260
|
+
const layer = useContext(PopoverLayerContext)
|
|
261
|
+
const isRaised = raised ?? layer?.raised ?? false
|
|
262
|
+
// Re-provided around the children: the Root can't see this prop, and a
|
|
263
|
+
// Popover nested in here reads it to stay above this one.
|
|
264
|
+
const raisedLayer = useMemo<PopoverLayerContextValue | null>(
|
|
265
|
+
() => (layer ? {...layer, raised: isRaised} : null),
|
|
266
|
+
[layer, isRaised],
|
|
267
|
+
)
|
|
246
268
|
|
|
247
269
|
// Measure before paint on open; re-measure on scroll/resize (capture phase
|
|
248
270
|
// catches any scrolling ancestor) and when Root.reposition() is called.
|
|
@@ -291,10 +313,13 @@ function Popup({children, label}: PopupProps) {
|
|
|
291
313
|
style={[
|
|
292
314
|
styles.popup,
|
|
293
315
|
inModal && styles.popupInModal,
|
|
316
|
+
isRaised && (inModal ? styles.popupInModalRaised : styles.popupRaised),
|
|
294
317
|
styles.popupPosition(coords.top, coords.left),
|
|
295
318
|
]}
|
|
296
319
|
>
|
|
297
|
-
<
|
|
320
|
+
<PopoverLayerContext.Provider value={raisedLayer}>
|
|
321
|
+
<ControlContextBoundary>{children}</ControlContextBoundary>
|
|
322
|
+
</PopoverLayerContext.Provider>
|
|
298
323
|
</html.div>,
|
|
299
324
|
mount ?? document.body,
|
|
300
325
|
)
|
|
@@ -5,6 +5,9 @@ import {type RefObject, createContext, useContext, useLayoutEffect} from 'react'
|
|
|
5
5
|
// on them counts as inside the Popover.
|
|
6
6
|
export interface PopoverLayerContextValue {
|
|
7
7
|
register: (el: Element) => () => void
|
|
8
|
+
/** Set by a `raised` Popup for what it holds, so a Popover nested in it is
|
|
9
|
+
* raised too and is not drawn under its parent. */
|
|
10
|
+
raised?: boolean
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export const PopoverLayerContext = createContext<PopoverLayerContextValue | null>(null)
|