@proteos/ui 0.57.1 → 0.58.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/index.cjs +552 -192
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +333 -22
- package/dist/index.d.ts +333 -22
- package/dist/index.js +540 -194
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/badge-multi-select/badge-multi-select.tsx +1 -1
- package/src/components/checkbox/checkbox-group.tsx +4 -1
- package/src/components/color-input/color-input.tsx +1 -1
- package/src/components/combobox/combobox.tsx +1 -1
- package/src/components/command/command-hint.tsx +1 -4
- package/src/components/command/command.stories.tsx +2 -2
- package/src/components/command/command.test.tsx +65 -3
- package/src/components/command/command.tsx +52 -7
- package/src/components/command/index.ts +16 -8
- package/src/components/currency-input/currency-input.tsx +1 -1
- package/src/components/date-picker/date-picker.tsx +1 -1
- package/src/components/date-time-picker/date-time-picker.tsx +1 -1
- package/src/components/field/field.tsx +6 -0
- package/src/components/icon-picker/icon-picker.tsx +1 -1
- package/src/components/input/input.tsx +1 -1
- package/src/components/multi-select/multi-select.tsx +1 -1
- package/src/components/progress-bar/progress-bar.stories.tsx +3 -1
- package/src/components/progress-bar/progress-bar.tsx +2 -0
- package/src/components/progress-ring/index.ts +2 -0
- package/src/components/progress-ring/progress-ring.stories.tsx +82 -0
- package/src/components/progress-ring/progress-ring.test.tsx +47 -0
- package/src/components/progress-ring/progress-ring.tsx +112 -0
- package/src/components/radio-group/radio-group.tsx +4 -1
- package/src/components/record-update/index.ts +17 -0
- package/src/components/record-update/record-update.stories.tsx +141 -0
- package/src/components/record-update/record-update.test.tsx +114 -0
- package/src/components/record-update/record-update.tsx +259 -0
- package/src/components/select/select.tsx +1 -1
- package/src/components/switch/switch.stories.tsx +22 -0
- package/src/components/switch/switch.test.tsx +6 -0
- package/src/components/switch/switch.tsx +40 -3
- package/src/components/tag-input/tag-input.tsx +1 -1
- package/src/components/text-shimmer/index.ts +2 -0
- package/src/components/text-shimmer/text-shimmer.stories.tsx +35 -0
- package/src/components/text-shimmer/text-shimmer.test.tsx +16 -0
- package/src/components/text-shimmer/text-shimmer.tsx +43 -0
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/time-field/time-field.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/lib/commands/index.ts +6 -2
- package/src/lib/commands/registry.test.ts +54 -1
- package/src/lib/commands/registry.ts +89 -4
- package/src/lib/commands/use-active-commands.test.tsx +56 -0
- package/src/lib/commands/use-active-commands.ts +16 -0
- package/src/lib/commands/use-command.ts +16 -5
- package/src/styles.css +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"license": "FSL-1.1-Apache-2.0",
|
|
5
5
|
"description": "Proteos React component library (React Aria Components + Tailwind v4).",
|
|
6
6
|
"repository": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"tailwind-variants": "^3.2.2",
|
|
57
57
|
"tailwindcss": "^4.2.4",
|
|
58
58
|
"thinking-orbs": "^0.3.1",
|
|
59
|
-
"@proteos/sdk": "0.
|
|
59
|
+
"@proteos/sdk": "0.58.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -78,8 +78,8 @@
|
|
|
78
78
|
"typescript": "^6.0.3",
|
|
79
79
|
"vite": "^8.0.10",
|
|
80
80
|
"vitest": "^3.2.4",
|
|
81
|
-
"@proteos/
|
|
82
|
-
"@proteos/
|
|
81
|
+
"@proteos/tsconfig": "0.0.0",
|
|
82
|
+
"@proteos/biome-config": "0.0.0"
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"lint": "biome check .",
|
|
@@ -206,7 +206,7 @@ export function BadgeMultiSelect<T extends Key>({
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
return (
|
|
209
|
-
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
209
|
+
<div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
|
|
210
210
|
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
211
211
|
|
|
212
212
|
<div
|
|
@@ -36,7 +36,10 @@ export function CheckboxGroup({
|
|
|
36
36
|
<AriaCheckboxGroup
|
|
37
37
|
{...props}
|
|
38
38
|
className={(renderProps) =>
|
|
39
|
-
cn(
|
|
39
|
+
cn(
|
|
40
|
+
'grid gap-2 content-start',
|
|
41
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
42
|
+
)
|
|
40
43
|
}
|
|
41
44
|
>
|
|
42
45
|
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
@@ -139,7 +139,7 @@ export function ColorInput({
|
|
|
139
139
|
// Canonical formatting once the user leaves: "ABC" → "#aabbcc".
|
|
140
140
|
onBlur={settle}
|
|
141
141
|
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
|
|
142
|
-
className={cn('grid grid-cols-1 gap-1.5', className)}
|
|
142
|
+
className={cn('grid grid-cols-1 gap-1.5 content-start', className)}
|
|
143
143
|
>
|
|
144
144
|
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
145
145
|
|
|
@@ -91,7 +91,7 @@ export function Combobox<T extends Key>({
|
|
|
91
91
|
allowsEmptyCollection={allowsEmptyCollection}
|
|
92
92
|
className={(renderProps) =>
|
|
93
93
|
cn(
|
|
94
|
-
'grid grid-cols-1 gap-1.5',
|
|
94
|
+
'grid grid-cols-1 gap-1.5 content-start',
|
|
95
95
|
typeof className === 'function' ? className(renderProps) : className,
|
|
96
96
|
)
|
|
97
97
|
}
|
|
@@ -30,10 +30,7 @@ export function CommandHint({ combo, tone = 'subtle', className }: CommandHintPr
|
|
|
30
30
|
const label = platform === 'mac' ? `${modSymbol}${keyLabel}` : `${modSymbol}+${keyLabel}`
|
|
31
31
|
return (
|
|
32
32
|
<Kbd
|
|
33
|
-
className={cn(
|
|
34
|
-
tone === 'prominent' && 'bg-ink text-surface border-transparent',
|
|
35
|
-
className,
|
|
36
|
-
)}
|
|
33
|
+
className={cn(tone === 'prominent' && 'bg-ink text-surface border-transparent', className)}
|
|
37
34
|
>
|
|
38
35
|
{label}
|
|
39
36
|
</Kbd>
|
|
@@ -35,8 +35,8 @@ export const Basic: Story = {
|
|
|
35
35
|
return (
|
|
36
36
|
<div className="flex flex-col items-start gap-3">
|
|
37
37
|
<p className="text-[13px] text-ink-3">
|
|
38
|
-
Press <strong>Cmd+J</strong> (or hold <strong>Cmd+Shift</strong> to peek the
|
|
39
|
-
|
|
38
|
+
Press <strong>Cmd+J</strong> (or hold <strong>Cmd+Shift</strong> to peek the shortcut,
|
|
39
|
+
then press <strong>J</strong>).
|
|
40
40
|
</p>
|
|
41
41
|
<Command combo="mod+j">
|
|
42
42
|
<Button onPress={() => setCount((c) => c + 1)}>Increment ({count})</Button>
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { act, render, screen } from '@testing-library/react'
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
|
|
4
|
-
import { Button } from '../button'
|
|
5
3
|
import { _resetDiscovery } from '../../lib/commands/discovery-store'
|
|
6
4
|
import { _setPlatformForTests } from '../../lib/commands/platform'
|
|
7
|
-
import { _resetRegistry } from '../../lib/commands/registry'
|
|
5
|
+
import { _resetRegistry, listActiveCommands } from '../../lib/commands/registry'
|
|
6
|
+
import { Button } from '../button'
|
|
8
7
|
|
|
9
8
|
import { Command } from './command'
|
|
10
9
|
|
|
@@ -100,3 +99,66 @@ describe('Command', () => {
|
|
|
100
99
|
expect(onPress).not.toHaveBeenCalled()
|
|
101
100
|
})
|
|
102
101
|
})
|
|
102
|
+
|
|
103
|
+
describe('Command — palette actions', () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
_setPlatformForTests('mac')
|
|
106
|
+
_resetRegistry()
|
|
107
|
+
_resetDiscovery()
|
|
108
|
+
})
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
_setPlatformForTests(undefined)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("registers the wrapped control's accessible name as the action label", () => {
|
|
114
|
+
const onPress = vi.fn()
|
|
115
|
+
render(
|
|
116
|
+
<>
|
|
117
|
+
<Command combo="mod+s">
|
|
118
|
+
<Button onPress={onPress}>Save</Button>
|
|
119
|
+
</Command>
|
|
120
|
+
<Command combo="mod+f">
|
|
121
|
+
<Button aria-label="Filter records">🔍</Button>
|
|
122
|
+
</Command>
|
|
123
|
+
<Command combo="mod+e" label="Edit record">
|
|
124
|
+
<Button>Edit</Button>
|
|
125
|
+
</Command>
|
|
126
|
+
</>,
|
|
127
|
+
)
|
|
128
|
+
const actions = listActiveCommands()
|
|
129
|
+
expect(actions.map((a) => [a.combo.raw, a.label])).toEqual([
|
|
130
|
+
['mod+s', 'Save'],
|
|
131
|
+
['mod+f', 'Filter records'],
|
|
132
|
+
['mod+e', 'Edit record'],
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
actions[0]?.invoke()
|
|
136
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('advertises the New shortcut as ⌘B on mac', () => {
|
|
140
|
+
render(
|
|
141
|
+
<Command combo="mod+b">
|
|
142
|
+
<Button>New</Button>
|
|
143
|
+
</Command>,
|
|
144
|
+
)
|
|
145
|
+
holdCmdShift()
|
|
146
|
+
expect(screen.getByText('⌘B')).toBeInTheDocument()
|
|
147
|
+
expect(listActiveCommands().map((a) => [a.combo.raw, a.label])).toEqual([['mod+b', 'New']])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('separates adjacent text pieces and skips aria-hidden decorations', () => {
|
|
151
|
+
render(
|
|
152
|
+
<Command combo="mod+f">
|
|
153
|
+
<Button>
|
|
154
|
+
<svg aria-hidden="true">
|
|
155
|
+
<title>funnel</title>
|
|
156
|
+
</svg>
|
|
157
|
+
Filter
|
|
158
|
+
<span>1</span>
|
|
159
|
+
</Button>
|
|
160
|
+
</Command>,
|
|
161
|
+
)
|
|
162
|
+
expect(listActiveCommands().map((a) => a.label)).toEqual(['Filter 1'])
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { type ReactNode, useRef } from 'react'
|
|
4
4
|
|
|
5
5
|
import { cn } from '../../lib/cn'
|
|
6
|
-
import { useCommand } from '../../lib/commands/use-command'
|
|
7
6
|
import { useDiscoveryActive } from '../../lib/commands/discovery-store'
|
|
7
|
+
import { useCommand } from '../../lib/commands/use-command'
|
|
8
8
|
|
|
9
9
|
import { CommandHint } from './command-hint'
|
|
10
10
|
|
|
@@ -33,6 +33,11 @@ import { CommandHint } from './command-hint'
|
|
|
33
33
|
call sites where the wrapper needs to participate in a flex/grid layout.
|
|
34
34
|
The wrapper stays `relative` either way so the discovery overlay can
|
|
35
35
|
absolute-position against it.
|
|
36
|
+
|
|
37
|
+
The binding is registered WITH a label — `label` when given, else the
|
|
38
|
+
target control's accessible name (aria-label, then its text) read when the
|
|
39
|
+
list is built — so the command palette can list it under "Actions" and
|
|
40
|
+
trigger it without the keyboard (`useActiveCommands`).
|
|
36
41
|
========================================================================= */
|
|
37
42
|
|
|
38
43
|
export type CommandProps = {
|
|
@@ -42,21 +47,31 @@ export type CommandProps = {
|
|
|
42
47
|
target?: string
|
|
43
48
|
/** Layout overrides for the wrapper. `relative` is always applied. */
|
|
44
49
|
className?: string
|
|
50
|
+
/** Name shown for this binding in the command palette. Defaults to the
|
|
51
|
+
* target control's accessible name. */
|
|
52
|
+
label?: string
|
|
45
53
|
children: ReactNode
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
export function Command({ combo, target, className, children }: CommandProps) {
|
|
56
|
+
export function Command({ combo, target, className, label, children }: CommandProps) {
|
|
49
57
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
50
58
|
const discoveryActive = useDiscoveryActive()
|
|
51
59
|
|
|
52
|
-
|
|
60
|
+
const resolveTarget = (): HTMLElement | null => {
|
|
53
61
|
const root = wrapperRef.current
|
|
54
|
-
if (!root) return
|
|
55
|
-
|
|
62
|
+
if (!root) return null
|
|
63
|
+
return target
|
|
56
64
|
? root.querySelector<HTMLElement>(target)
|
|
57
65
|
: (root.firstElementChild as HTMLElement | null)
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
useCommand(
|
|
69
|
+
combo,
|
|
70
|
+
() => {
|
|
71
|
+
resolveTarget()?.click()
|
|
72
|
+
},
|
|
73
|
+
{ label: () => label ?? accessibleName(resolveTarget()) },
|
|
74
|
+
)
|
|
60
75
|
|
|
61
76
|
return (
|
|
62
77
|
<div ref={wrapperRef} className={cn('relative inline-block', className)}>
|
|
@@ -74,3 +89,33 @@ export function Command({ combo, target, className, children }: CommandProps) {
|
|
|
74
89
|
</div>
|
|
75
90
|
)
|
|
76
91
|
}
|
|
92
|
+
|
|
93
|
+
/** The control's name as assistive tech would read it: an explicit aria-label
|
|
94
|
+
* first, else its visible text — each text piece separated by a space (so
|
|
95
|
+
* "Filter" next to a count chip reads "Filter 1", not "Filter1"), with
|
|
96
|
+
* `aria-hidden` decorations (icons) left out. */
|
|
97
|
+
function accessibleName(el: HTMLElement | null): string | undefined {
|
|
98
|
+
if (!el) return undefined
|
|
99
|
+
const aria = el.getAttribute('aria-label')?.trim()
|
|
100
|
+
if (aria) return aria
|
|
101
|
+
const pieces: string[] = []
|
|
102
|
+
const walk = (node: Node): void => {
|
|
103
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
104
|
+
const text = node.textContent?.trim()
|
|
105
|
+
if (text) pieces.push(text)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
// `Element`, not `HTMLElement`: an inline SVG icon is an SVGElement.
|
|
109
|
+
if (node instanceof Element) {
|
|
110
|
+
if (node.getAttribute('aria-hidden') === 'true') return
|
|
111
|
+
const own = node.getAttribute('aria-label')?.trim()
|
|
112
|
+
if (own) {
|
|
113
|
+
pieces.push(own)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const child of node.childNodes) walk(child)
|
|
118
|
+
}
|
|
119
|
+
walk(el)
|
|
120
|
+
return pieces.length > 0 ? pieces.join(' ') : undefined
|
|
121
|
+
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
export type { CommandProps } from './command'
|
|
2
|
-
export { Command } from './command'
|
|
3
|
-
export type { CommandHintProps, CommandHintTone } from './command-hint'
|
|
4
|
-
export { CommandHint } from './command-hint'
|
|
5
|
-
|
|
6
1
|
// Re-export the command-system primitives so consumers can `import { useCommand,
|
|
7
|
-
// useEscapeKey, parseCombo, useDiscoveryActive } from '@proteos/ui'`.
|
|
8
|
-
export type {
|
|
2
|
+
// useEscapeKey, parseCombo, useDiscoveryActive, useActiveCommands, getPlatform } from '@proteos/ui'`.
|
|
3
|
+
export type {
|
|
4
|
+
ActiveCommand,
|
|
5
|
+
CommandLabel,
|
|
6
|
+
KeyCombo,
|
|
7
|
+
Platform,
|
|
8
|
+
UseCommandOptions,
|
|
9
|
+
UseEscapeKeyOptions,
|
|
10
|
+
} from '../../lib/commands'
|
|
9
11
|
export {
|
|
12
|
+
getPlatform,
|
|
10
13
|
isEditableTarget,
|
|
11
|
-
parseCombo,
|
|
12
14
|
PROTECTED_COMBOS,
|
|
15
|
+
parseCombo,
|
|
16
|
+
useActiveCommands,
|
|
13
17
|
useCommand,
|
|
14
18
|
useDiscoveryActive,
|
|
15
19
|
useEscapeKey,
|
|
16
20
|
} from '../../lib/commands'
|
|
21
|
+
export type { CommandProps } from './command'
|
|
22
|
+
export { Command } from './command'
|
|
23
|
+
export type { CommandHintProps, CommandHintTone } from './command-hint'
|
|
24
|
+
export { CommandHint } from './command-hint'
|
|
@@ -101,7 +101,7 @@ export function CurrencyInput({
|
|
|
101
101
|
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
102
102
|
|
|
103
103
|
return (
|
|
104
|
-
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
104
|
+
<div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
|
|
105
105
|
<div className={cn(styles.box(), 'flex items-center gap-1 pr-1', focusWithin)}>
|
|
106
106
|
<input
|
|
107
107
|
className={cn(
|
|
@@ -53,7 +53,7 @@ export function DatePicker<T extends DateValue>({
|
|
|
53
53
|
{...props}
|
|
54
54
|
className={(renderProps) =>
|
|
55
55
|
cn(
|
|
56
|
-
'grid grid-cols-1 gap-1.5',
|
|
56
|
+
'grid grid-cols-1 gap-1.5 content-start',
|
|
57
57
|
typeof className === 'function' ? className(renderProps) : className,
|
|
58
58
|
)
|
|
59
59
|
}
|
|
@@ -123,7 +123,7 @@ export function DateTimePicker<T extends DateValue>({
|
|
|
123
123
|
shouldCloseOnSelect={shouldCloseOnSelect}
|
|
124
124
|
className={(renderProps) =>
|
|
125
125
|
cn(
|
|
126
|
-
'grid grid-cols-1 gap-1.5',
|
|
126
|
+
'grid grid-cols-1 gap-1.5 content-start',
|
|
127
127
|
typeof className === 'function' ? className(renderProps) : className,
|
|
128
128
|
)
|
|
129
129
|
}
|
|
@@ -127,6 +127,12 @@ export const fieldChipPadding: Record<FieldSize, string> = {
|
|
|
127
127
|
Label / hint / error text styles — applied to RAC's Label / Text / FieldError
|
|
128
128
|
components inside each form control. Strings rather than components so the
|
|
129
129
|
existing monolithic APIs can keep their inline composition.
|
|
130
|
+
|
|
131
|
+
Every control stacks them on a `grid grid-cols-1 gap-1.5 content-start`
|
|
132
|
+
root (groups: `grid gap-2 content-start`). `content-start` matters: a
|
|
133
|
+
consumer grid stretches its cells, and without it the auto rows of a
|
|
134
|
+
field with no hint absorb the extra height — its label drifts and its box
|
|
135
|
+
grows to match a sibling that has one. Copy the recipe for new controls.
|
|
130
136
|
========================================================================= */
|
|
131
137
|
|
|
132
138
|
export const fieldLabelStyles = 'font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4'
|
|
@@ -157,7 +157,7 @@ export function IconPicker({
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
return (
|
|
160
|
-
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
160
|
+
<div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
|
|
161
161
|
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
162
162
|
|
|
163
163
|
{compact ? (
|
|
@@ -123,7 +123,7 @@ export function MultiSelect<T extends Key>({
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
return (
|
|
126
|
-
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
126
|
+
<div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
|
|
127
127
|
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
128
128
|
|
|
129
129
|
<div
|
|
@@ -15,7 +15,7 @@ const meta: Meta<typeof ProgressBar> = {
|
|
|
15
15
|
argTypes: {
|
|
16
16
|
size: {
|
|
17
17
|
control: { type: 'inline-radio' },
|
|
18
|
-
options: ['sm', 'md'],
|
|
18
|
+
options: ['xs', 'sm', 'md'],
|
|
19
19
|
},
|
|
20
20
|
isIndeterminate: { control: { type: 'boolean' } },
|
|
21
21
|
value: { control: { type: 'number', min: 0, max: 100 } },
|
|
@@ -66,6 +66,8 @@ export const Sizes: Story = {
|
|
|
66
66
|
parameters: { controls: { hideNoControlsWarning: true } },
|
|
67
67
|
render: () => (
|
|
68
68
|
<div className="grid grid-cols-[80px_1fr] items-center gap-x-6 gap-y-4 w-[360px]">
|
|
69
|
+
<div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">xs</div>
|
|
70
|
+
<ProgressBar value={60} size="xs" aria-label="Progress xs" />
|
|
69
71
|
<div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">sm</div>
|
|
70
72
|
<ProgressBar value={60} size="sm" aria-label="Progress sm" />
|
|
71
73
|
<div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">md</div>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { ProgressRing } from './progress-ring'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ProgressRing> = {
|
|
6
|
+
title: 'Components/ProgressRing',
|
|
7
|
+
component: ProgressRing,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
args: {
|
|
10
|
+
'aria-label': 'Confidence',
|
|
11
|
+
size: 'sm',
|
|
12
|
+
value: 82,
|
|
13
|
+
},
|
|
14
|
+
argTypes: {
|
|
15
|
+
size: {
|
|
16
|
+
control: { type: 'inline-radio' },
|
|
17
|
+
options: ['xs', 'sm', 'md'],
|
|
18
|
+
},
|
|
19
|
+
isIndeterminate: { control: { type: 'boolean' } },
|
|
20
|
+
value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default meta
|
|
25
|
+
type Story = StoryObj<typeof ProgressRing>
|
|
26
|
+
|
|
27
|
+
export const Playground: Story = {}
|
|
28
|
+
|
|
29
|
+
export const Sizes: Story = {
|
|
30
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
31
|
+
render: () => (
|
|
32
|
+
<div className="grid grid-cols-[80px_auto] items-center gap-x-6 gap-y-4">
|
|
33
|
+
<div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">xs</div>
|
|
34
|
+
<ProgressRing value={82} size="xs" aria-label="Confidence xs" />
|
|
35
|
+
<div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">sm</div>
|
|
36
|
+
<ProgressRing value={82} size="sm" aria-label="Confidence sm" />
|
|
37
|
+
<div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">md</div>
|
|
38
|
+
<ProgressRing value={82} size="md" aria-label="Confidence md" />
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const Values: Story = {
|
|
44
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
45
|
+
render: () => (
|
|
46
|
+
<div className="flex items-center gap-4">
|
|
47
|
+
{[0, 33, 82, 100].map((value) => (
|
|
48
|
+
<ProgressRing key={value} value={value} size="md" aria-label={`${value} percent`} />
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const InARow: Story = {
|
|
55
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
56
|
+
render: () => (
|
|
57
|
+
<div className="flex flex-col gap-1 w-[320px] rounded-md border border-rule-2 px-2.5 py-2">
|
|
58
|
+
<div className="flex items-start gap-2">
|
|
59
|
+
<p className="flex-1 min-w-0 text-[12.5px] text-ink-2">
|
|
60
|
+
<span className="font-medium text-ink">Pricing mentioned</span>
|
|
61
|
+
</p>
|
|
62
|
+
<ProgressRing
|
|
63
|
+
size="xs"
|
|
64
|
+
className="mt-0.5"
|
|
65
|
+
value={82}
|
|
66
|
+
aria-label="Pricing mentioned confidence"
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
<p className="text-[12px] leading-[1.45] text-ink-2">Asked about the annual plan.</p>
|
|
70
|
+
</div>
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const Indeterminate: Story = {
|
|
75
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
76
|
+
render: () => (
|
|
77
|
+
<div className="flex items-center gap-4">
|
|
78
|
+
<ProgressRing isIndeterminate aria-label="Evaluating" />
|
|
79
|
+
<ProgressRing isIndeterminate size="md" aria-label="Evaluating md" />
|
|
80
|
+
</div>
|
|
81
|
+
),
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { ProgressRing } from './progress-ring'
|
|
5
|
+
|
|
6
|
+
describe('ProgressRing', () => {
|
|
7
|
+
it('renders with the progressbar role and accessible name', () => {
|
|
8
|
+
render(<ProgressRing value={40} aria-label="Confidence" />)
|
|
9
|
+
expect(screen.getByRole('progressbar', { name: 'Confidence' })).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('reflects the value in aria-valuenow and centres the percent text', () => {
|
|
13
|
+
render(<ProgressRing value={82} aria-label="Confidence" />)
|
|
14
|
+
expect(screen.getByRole('progressbar', { name: 'Confidence' })).toHaveAttribute(
|
|
15
|
+
'aria-valuenow',
|
|
16
|
+
'82',
|
|
17
|
+
)
|
|
18
|
+
expect(screen.getByText('82%')).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('shows a custom valueLabel instead of the percent', () => {
|
|
22
|
+
render(<ProgressRing value={4} maxValue={5} valueLabel="4/5" aria-label="Steps" />)
|
|
23
|
+
expect(screen.getByText('4/5')).toBeInTheDocument()
|
|
24
|
+
expect(screen.queryByText('80%')).not.toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('omits aria-valuenow and the value text when indeterminate', () => {
|
|
28
|
+
render(<ProgressRing isIndeterminate aria-label="Evaluating" />)
|
|
29
|
+
const ring = screen.getByRole('progressbar', { name: 'Evaluating' })
|
|
30
|
+
expect(ring).not.toHaveAttribute('aria-valuenow')
|
|
31
|
+
expect(ring).toHaveTextContent('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('sets the value beside a 14px xs ring', () => {
|
|
35
|
+
render(<ProgressRing size="xs" value={82} aria-label="Confidence" />)
|
|
36
|
+
const ring = screen.getByRole('progressbar', { name: 'Confidence' })
|
|
37
|
+
expect(ring).toHaveAttribute('aria-valuenow', '82')
|
|
38
|
+
expect(ring.querySelector('svg')).toHaveAttribute('viewBox', '0 0 14 14')
|
|
39
|
+
expect(screen.getByText('82%')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('still renders the value at zero', () => {
|
|
43
|
+
render(<ProgressRing value={0} aria-label="Empty" />)
|
|
44
|
+
expect(screen.getByRole('progressbar', { name: 'Empty' })).toHaveAttribute('aria-valuenow', '0')
|
|
45
|
+
expect(screen.getByText('0%')).toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ProgressBar as AriaProgressBar,
|
|
3
|
+
type ProgressBarProps as AriaProgressBarProps,
|
|
4
|
+
} from 'react-aria-components'
|
|
5
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/cn'
|
|
8
|
+
|
|
9
|
+
/* =========================================================================
|
|
10
|
+
ProgressRing — the circular sibling of ProgressBar: RAC's headless
|
|
11
|
+
ProgressBar (state + ARIA) around an SVG track + arc, with the formatted
|
|
12
|
+
value (RAC's `valueText`, percent by default) set beside or inside the
|
|
13
|
+
ring. For the compact "how sure / how far" read beside a title — a
|
|
14
|
+
confidence, a quota, a completion — where a full-width bar would
|
|
15
|
+
dominate the row.
|
|
16
|
+
|
|
17
|
+
Sizes: `xs` is a 14px glyph with the value in mono BESIDE it (digits
|
|
18
|
+
cannot live inside a ring that small); `sm` (28px) and `md` (40px) centre
|
|
19
|
+
the value inside. Determinate (`value`) draws the arc clockwise from
|
|
20
|
+
12 o'clock; both circles carry `pathLength={100}` so the dash length IS
|
|
21
|
+
the percentage. Indeterminate (`isIndeterminate`) spins a quarter arc
|
|
22
|
+
and hides the value. `aria-label` (or `aria-labelledby`) is required —
|
|
23
|
+
the ring has no visible label of its own.
|
|
24
|
+
========================================================================= */
|
|
25
|
+
|
|
26
|
+
export const progressRingStyles = tv({
|
|
27
|
+
slots: {
|
|
28
|
+
root: 'shrink-0 align-middle',
|
|
29
|
+
svg: 'block shrink-0 -rotate-90',
|
|
30
|
+
track: 'fill-none',
|
|
31
|
+
arc: 'fill-none stroke-accent transition-[stroke-dasharray] duration-300 ease-out',
|
|
32
|
+
value: 'font-mono tabular-nums leading-none',
|
|
33
|
+
},
|
|
34
|
+
variants: {
|
|
35
|
+
size: {
|
|
36
|
+
xs: {
|
|
37
|
+
root: 'inline-flex items-center gap-1.5',
|
|
38
|
+
svg: 'size-3.5',
|
|
39
|
+
// A hair darker than the larger rings' track: a 2px hairline on a
|
|
40
|
+
// tinted card vanishes at surface-2.
|
|
41
|
+
track: 'stroke-surface-3',
|
|
42
|
+
value: 'text-[11px] text-ink-4',
|
|
43
|
+
},
|
|
44
|
+
sm: {
|
|
45
|
+
root: 'relative inline-grid place-items-center size-7',
|
|
46
|
+
svg: 'col-start-1 row-start-1 size-full',
|
|
47
|
+
track: 'stroke-surface-2',
|
|
48
|
+
value: 'col-start-1 row-start-1 text-[8.5px] text-ink-2',
|
|
49
|
+
},
|
|
50
|
+
md: {
|
|
51
|
+
root: 'relative inline-grid place-items-center size-10',
|
|
52
|
+
svg: 'col-start-1 row-start-1 size-full',
|
|
53
|
+
track: 'stroke-surface-2',
|
|
54
|
+
value: 'col-start-1 row-start-1 text-[10px] text-ink-2',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: { size: 'sm' },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** Pixel geometry per size — tv() carries classes, the SVG needs numbers. */
|
|
62
|
+
const GEOMETRY = {
|
|
63
|
+
xs: { box: 14, stroke: 2 },
|
|
64
|
+
sm: { box: 28, stroke: 2.5 },
|
|
65
|
+
md: { box: 40, stroke: 3 },
|
|
66
|
+
} as const
|
|
67
|
+
|
|
68
|
+
export type ProgressRingVariants = VariantProps<typeof progressRingStyles>
|
|
69
|
+
export type ProgressRingProps = Omit<AriaProgressBarProps, 'children' | 'className'> &
|
|
70
|
+
ProgressRingVariants & {
|
|
71
|
+
className?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function ProgressRing({ size, className, ...props }: ProgressRingProps) {
|
|
75
|
+
const resolvedSize = size ?? 'sm'
|
|
76
|
+
const { root, svg, track, arc, value } = progressRingStyles({ size: resolvedSize })
|
|
77
|
+
const { box, stroke } = GEOMETRY[resolvedSize]
|
|
78
|
+
const center = box / 2
|
|
79
|
+
const radius = (box - stroke) / 2
|
|
80
|
+
return (
|
|
81
|
+
<AriaProgressBar {...props} className={cn(root(), className)}>
|
|
82
|
+
{({ percentage, valueText, isIndeterminate }) => {
|
|
83
|
+
const shown = isIndeterminate ? 25 : Math.max(0, Math.min(100, percentage ?? 0))
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<svg
|
|
87
|
+
aria-hidden
|
|
88
|
+
viewBox={`0 0 ${box} ${box}`}
|
|
89
|
+
className={cn(svg(), isIndeterminate && 'animate-spin')}
|
|
90
|
+
>
|
|
91
|
+
<circle cx={center} cy={center} r={radius} strokeWidth={stroke} className={track()} />
|
|
92
|
+
{/* A zero-length dash with round caps would still paint a dot. */}
|
|
93
|
+
{shown > 0 ? (
|
|
94
|
+
<circle
|
|
95
|
+
cx={center}
|
|
96
|
+
cy={center}
|
|
97
|
+
r={radius}
|
|
98
|
+
strokeWidth={stroke}
|
|
99
|
+
strokeLinecap="round"
|
|
100
|
+
pathLength={100}
|
|
101
|
+
strokeDasharray={`${shown} 100`}
|
|
102
|
+
className={arc()}
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
</svg>
|
|
106
|
+
{isIndeterminate ? null : <span className={value()}>{valueText}</span>}
|
|
107
|
+
</>
|
|
108
|
+
)
|
|
109
|
+
}}
|
|
110
|
+
</AriaProgressBar>
|
|
111
|
+
)
|
|
112
|
+
}
|