@proteos/ui 0.47.0 → 0.49.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 +39 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -2
- package/dist/index.d.ts +35 -2
- package/dist/index.js +38 -6
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/components/channel-icon/channel-icon.tsx +2 -0
- package/src/components/orb/index.ts +2 -0
- package/src/components/orb/orb.stories.tsx +25 -0
- package/src/components/orb/orb.tsx +57 -0
- package/src/components/record-filter/chip-value.tsx +17 -2
- package/src/components/record-filter/field-label.ts +29 -0
- package/src/components/record-filter/index.ts +1 -0
- package/src/components/record-filter/record-filter-chips.tsx +7 -1
- package/src/components/record-filter/record-filter.stories.tsx +3 -3
- package/src/components/record-filter/record-filter.test.tsx +86 -0
- package/src/components/record-filter/types.ts +2 -1
- package/src/index.ts +1 -0
- package/src/styles.css +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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": {
|
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
"tailwind-merge": "^3.5.0",
|
|
56
56
|
"tailwind-variants": "^3.2.2",
|
|
57
57
|
"tailwindcss": "^4.2.4",
|
|
58
|
-
"
|
|
58
|
+
"thinking-orbs": "^0.3.1",
|
|
59
|
+
"@proteos/sdk": "0.49.0"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
62
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -77,8 +78,8 @@
|
|
|
77
78
|
"typescript": "^6.0.3",
|
|
78
79
|
"vite": "^8.0.10",
|
|
79
80
|
"vitest": "^3.2.4",
|
|
80
|
-
"@proteos/
|
|
81
|
-
"@proteos/
|
|
81
|
+
"@proteos/tsconfig": "0.0.0",
|
|
82
|
+
"@proteos/biome-config": "0.0.0"
|
|
82
83
|
},
|
|
83
84
|
"scripts": {
|
|
84
85
|
"lint": "biome check .",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
MessageCircle,
|
|
10
10
|
MessagesSquare,
|
|
11
11
|
Mic,
|
|
12
|
+
Phone,
|
|
12
13
|
Send,
|
|
13
14
|
Video,
|
|
14
15
|
} from 'lucide-react'
|
|
@@ -41,6 +42,7 @@ const CHANNEL_ICONS: Readonly<Record<string, LucideIcon>> = {
|
|
|
41
42
|
'teams-meeting': Video,
|
|
42
43
|
'webex-meeting': Video,
|
|
43
44
|
adhoc: AudioLines,
|
|
45
|
+
phone: Phone,
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** The channel's glyph component. Unknown channels get the generic one. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Orb } from './orb'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Orb> = {
|
|
6
|
+
title: 'Components/Orb',
|
|
7
|
+
component: Orb,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
parameters: { layout: 'padded' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof Orb>
|
|
14
|
+
|
|
15
|
+
export const Sizes: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<div className="flex items-center gap-4">
|
|
18
|
+
<Orb size="xs" label="Agent working" />
|
|
19
|
+
<Orb size="sm" label="Agent working" />
|
|
20
|
+
<Orb size="md" label="Agent working" />
|
|
21
|
+
<Orb size="lg" label="Agent working" />
|
|
22
|
+
<span className="text-[13px] text-ink-3">Agent is composing…</span>
|
|
23
|
+
</div>
|
|
24
|
+
),
|
|
25
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type OrbState, type OrbTheme, ThinkingOrb } from 'thinking-orbs'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn'
|
|
4
|
+
|
|
5
|
+
/* =========================================================================
|
|
6
|
+
Orb — the "agent is working" indicator, backed by the `thinking-orbs`
|
|
7
|
+
package (https://orbs.jakubantalik.com): a canvas-rendered dotted orb
|
|
8
|
+
(state: solving), monochrome ink that auto-resolves against `data-theme`
|
|
9
|
+
/ OS scheme, with a reduced-motion static frame built in.
|
|
10
|
+
|
|
11
|
+
The package ships two hand-tuned designs — 20px (inline) and 64px
|
|
12
|
+
(avatar). `sm` and `lg` render them at native size; `xs` gently
|
|
13
|
+
downscales the 20px design, `md` downscales the 64px one (downscaling
|
|
14
|
+
stays crisp — the canvas buffer is the larger design).
|
|
15
|
+
|
|
16
|
+
Purely decorative by default — pass `label` to expose it as an img with
|
|
17
|
+
an accessible name (e.g. "Agent working").
|
|
18
|
+
========================================================================= */
|
|
19
|
+
|
|
20
|
+
export type OrbSizeToken = 'xs' | 'sm' | 'md' | 'lg'
|
|
21
|
+
|
|
22
|
+
/** CSS box per token. sm/lg match the native designs exactly (no scaling). */
|
|
23
|
+
const SIZE_PX: Record<OrbSizeToken, number> = { xs: 12, sm: 20, md: 40, lg: 64 }
|
|
24
|
+
|
|
25
|
+
/** Which native design each token rides. */
|
|
26
|
+
const DESIGN: Record<OrbSizeToken, 20 | 64> = { xs: 20, sm: 20, md: 64, lg: 64 }
|
|
27
|
+
|
|
28
|
+
export interface OrbProps {
|
|
29
|
+
/** xs 12 · sm 20 (native inline) · md 40 · lg 64 (native avatar). Default `sm`. */
|
|
30
|
+
size?: OrbSizeToken
|
|
31
|
+
/** Animation state; defaults to `solving` (the working indicator). */
|
|
32
|
+
state?: OrbState
|
|
33
|
+
/** Speed multiplier (1 = tuned speed); e.g. 0.2 for a slow idle shimmer. */
|
|
34
|
+
speed?: number
|
|
35
|
+
/** Pin the ink instead of auto-detecting — `dark` renders light dots (for
|
|
36
|
+
* a dark chip like the launcher's black circle). */
|
|
37
|
+
theme?: OrbTheme
|
|
38
|
+
className?: string
|
|
39
|
+
/** Accessible name; omitting it hides the orb from assistive tech. */
|
|
40
|
+
label?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Orb({ size = 'sm', state = 'solving', speed, theme, className, label }: OrbProps) {
|
|
44
|
+
const px = SIZE_PX[size]
|
|
45
|
+
const design = DESIGN[size]
|
|
46
|
+
return (
|
|
47
|
+
<ThinkingOrb
|
|
48
|
+
state={state}
|
|
49
|
+
size={design}
|
|
50
|
+
{...(speed !== undefined ? { speed } : {})}
|
|
51
|
+
{...(theme !== undefined ? { theme } : {})}
|
|
52
|
+
className={cn('inline-block shrink-0', className)}
|
|
53
|
+
{...(px !== design ? { style: { width: px, height: px } } : {})}
|
|
54
|
+
{...(label ? { 'aria-label': label } : { 'aria-hidden': true })}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -124,7 +124,7 @@ function ChipItem({
|
|
|
124
124
|
}
|
|
125
125
|
// Lib default: enum → Badge with variant lookup; other → truncated mono text.
|
|
126
126
|
if (field.type === 'enum') {
|
|
127
|
-
const option = field
|
|
127
|
+
const option = findEnumOption(field, value)
|
|
128
128
|
return (
|
|
129
129
|
<Badge
|
|
130
130
|
variant={option?.variant}
|
|
@@ -146,6 +146,13 @@ function Em({ children }: { children: string }) {
|
|
|
146
146
|
return <span className="font-mono text-[12px] text-ink-4">{children}</span>
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** The declared option for an enum value, if the field carries one. Central so
|
|
150
|
+
* the badge, the multi-value summary and the single-value summary agree on
|
|
151
|
+
* what a stored value is called. */
|
|
152
|
+
function findEnumOption(field: FilterField, value: string) {
|
|
153
|
+
return field.options?.find((option) => option.value === value)
|
|
154
|
+
}
|
|
155
|
+
|
|
149
156
|
export function formatValue(
|
|
150
157
|
field: FilterField,
|
|
151
158
|
operator: ComparisonOperator,
|
|
@@ -161,7 +168,7 @@ export function formatValue(
|
|
|
161
168
|
: []
|
|
162
169
|
if (arr.length === 0) return '—'
|
|
163
170
|
if (field.type === 'enum') {
|
|
164
|
-
const labels = arr.map((v) => field
|
|
171
|
+
const labels = arr.map((v) => findEnumOption(field, v)?.label ?? v)
|
|
165
172
|
return labels.length <= 2
|
|
166
173
|
? labels.join(', ')
|
|
167
174
|
: `${labels[0]}, ${labels[1]} +${labels.length - 2}`
|
|
@@ -170,6 +177,14 @@ export function formatValue(
|
|
|
170
177
|
}
|
|
171
178
|
if (field.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false'
|
|
172
179
|
if (value === '' || value == null) return '—'
|
|
180
|
+
// An enum stores a system value ("technical_decider") and displays its
|
|
181
|
+
// declared label ("Technical Decider"). The multi-value branch above and the
|
|
182
|
+
// badge below already do this; a single value read as the raw slug, so the
|
|
183
|
+
// same filter said "is technical_decider" or "is any of Technical Decider"
|
|
184
|
+
// depending only on the operator.
|
|
185
|
+
if (field.type === 'enum') {
|
|
186
|
+
return findEnumOption(field, String(value))?.label ?? String(value)
|
|
187
|
+
}
|
|
173
188
|
if (field.type === 'number' || field.type === 'integer') {
|
|
174
189
|
const n = Number(value)
|
|
175
190
|
return Number.isFinite(n) ? n.toLocaleString() : String(value)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* =========================================================================
|
|
2
|
+
Field-label composition — how a nested or related field reads in the
|
|
3
|
+
picker, the chips rail and the operator row.
|
|
4
|
+
|
|
5
|
+
A `FilterField` path is hierarchical: an object leaf (`address.city`) and
|
|
6
|
+
a field reached through a relation (`company_id.name`) both render as a
|
|
7
|
+
breadcrumb. The separator lives here because BOTH sides compose it — the
|
|
8
|
+
consumer builds `FilterField.label` from an entity, and the chips rail
|
|
9
|
+
builds its own breadcrumb from `parentLabel` — and a mismatch between the
|
|
10
|
+
two shows up as one chip reading "Company · Name" next to another reading
|
|
11
|
+
"Company / Name".
|
|
12
|
+
========================================================================= */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* U+00B7 MIDDLE DOT, spaced. The same separator the app already uses for
|
|
16
|
+
* breadcrumb-ish text ("Segment · SaaS decision makers"), and quieter than a
|
|
17
|
+
* slash at the small type sizes these labels render at — the eye reads the
|
|
18
|
+
* words, not the punctuation.
|
|
19
|
+
*/
|
|
20
|
+
export const FIELD_LABEL_SEPARATOR = ' · '
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Joins breadcrumb parts with {@link FIELD_LABEL_SEPARATOR}, skipping empties
|
|
24
|
+
* so a field with no parent renders as its own label rather than with a
|
|
25
|
+
* leading separator.
|
|
26
|
+
*/
|
|
27
|
+
export function joinFieldLabel(...parts: ReadonlyArray<string | undefined | null>): string {
|
|
28
|
+
return parts.filter((part): part is string => !!part && part.length > 0).join(FIELD_LABEL_SEPARATOR)
|
|
29
|
+
}
|
|
@@ -14,6 +14,7 @@ import { OperatorPicker } from './operator-picker'
|
|
|
14
14
|
import { operatorById, operatorsForType } from './operators'
|
|
15
15
|
import { useShowDetails } from './show-details-store'
|
|
16
16
|
import { AttributeTypeIcon } from '../attribute-type-icon'
|
|
17
|
+
import { FIELD_LABEL_SEPARATOR } from './field-label'
|
|
17
18
|
import type { ComparisonOperator, FilterField, FilterGroup, FilterValue } from './types'
|
|
18
19
|
|
|
19
20
|
/* =========================================================================
|
|
@@ -370,7 +371,12 @@ function Chip({
|
|
|
370
371
|
</span>
|
|
371
372
|
) : null}
|
|
372
373
|
<span className="font-sans text-[12.5px] text-ink-2">
|
|
373
|
-
{field.parentLabel ?
|
|
374
|
+
{field.parentLabel ? (
|
|
375
|
+
<span className="text-ink-4">
|
|
376
|
+
{field.parentLabel}
|
|
377
|
+
{FIELD_LABEL_SEPARATOR}
|
|
378
|
+
</span>
|
|
379
|
+
) : null}
|
|
374
380
|
<span className="text-ink">{field.shortLabel}</span>
|
|
375
381
|
</span>
|
|
376
382
|
<span className="font-mono text-[11px] text-ink-3">{op?.label}</span>
|
|
@@ -52,14 +52,14 @@ const customerFields: FilterField[] = [
|
|
|
52
52
|
path: 'owner.name',
|
|
53
53
|
shortLabel: 'Name',
|
|
54
54
|
parentLabel: 'Owner',
|
|
55
|
-
label: 'Owner
|
|
55
|
+
label: 'Owner · Name',
|
|
56
56
|
type: 'string',
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
path: 'owner.email',
|
|
60
60
|
shortLabel: 'Email',
|
|
61
61
|
parentLabel: 'Owner',
|
|
62
|
-
label: 'Owner
|
|
62
|
+
label: 'Owner · Email',
|
|
63
63
|
type: 'string',
|
|
64
64
|
inputType: 'email',
|
|
65
65
|
},
|
|
@@ -67,7 +67,7 @@ const customerFields: FilterField[] = [
|
|
|
67
67
|
path: 'address.country',
|
|
68
68
|
shortLabel: 'Country',
|
|
69
69
|
parentLabel: 'Address',
|
|
70
|
-
label: 'Address
|
|
70
|
+
label: 'Address · Country',
|
|
71
71
|
type: 'enum',
|
|
72
72
|
options: [
|
|
73
73
|
{ value: 'us', label: 'United States' },
|
|
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
5
5
|
|
|
6
6
|
import { RecordFilterBuilderPanel } from './record-filter-builder-panel'
|
|
7
7
|
import { RecordFilterButton } from './record-filter-button'
|
|
8
|
+
import { formatValue } from './chip-value'
|
|
9
|
+
import { FIELD_LABEL_SEPARATOR, joinFieldLabel } from './field-label'
|
|
8
10
|
import { RecordFilterChips } from './record-filter-chips'
|
|
9
11
|
import { __resetShowDetailsStores } from './show-details-store'
|
|
10
12
|
import type { FilterField, FilterGroup } from './types'
|
|
@@ -435,3 +437,87 @@ describe('RecordFilterChips — nested groups', () => {
|
|
|
435
437
|
expect(screen.getByText(/· 2/)).toBeInTheDocument()
|
|
436
438
|
})
|
|
437
439
|
})
|
|
440
|
+
|
|
441
|
+
/* The chip rail composes its own breadcrumb from `parentLabel`, while the
|
|
442
|
+
consumer composes `label` when it builds the field list. Both must use
|
|
443
|
+
FIELD_LABEL_SEPARATOR, or one chip reads "Company · Name" beside another
|
|
444
|
+
reading "Company / Name". */
|
|
445
|
+
describe('RecordFilterChips — breadcrumb separator', () => {
|
|
446
|
+
const nestedFields: FilterField[] = [
|
|
447
|
+
{
|
|
448
|
+
path: 'billing_address.city',
|
|
449
|
+
label: `Billing Address${FIELD_LABEL_SEPARATOR}City`,
|
|
450
|
+
shortLabel: 'City',
|
|
451
|
+
parentLabel: 'Billing Address',
|
|
452
|
+
type: 'string',
|
|
453
|
+
},
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
it('separates the parent from the leaf with a middle dot', () => {
|
|
457
|
+
// Chips only render when expanded; the store defaults to collapsed.
|
|
458
|
+
window.localStorage.setItem('rf.showDetails', '1')
|
|
459
|
+
render(
|
|
460
|
+
<RecordFilterChips
|
|
461
|
+
fields={nestedFields}
|
|
462
|
+
filter={{
|
|
463
|
+
logicalOperator: 'and',
|
|
464
|
+
elements: [{ field: 'billing_address.city', operator: 'eq', value: 'Berlin' }],
|
|
465
|
+
}}
|
|
466
|
+
onChange={() => undefined}
|
|
467
|
+
/>,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
expect(screen.getByText('Billing Address', { exact: false }).textContent).toContain(
|
|
471
|
+
FIELD_LABEL_SEPARATOR,
|
|
472
|
+
)
|
|
473
|
+
expect(document.body.textContent).not.toContain('Billing Address / ')
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('exposes the separator as the middle dot', () => {
|
|
477
|
+
expect(FIELD_LABEL_SEPARATOR).toBe(' · ')
|
|
478
|
+
expect(joinFieldLabel('Company', undefined, 'Name')).toBe('Company · Name')
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
/* An enum stores a system value and displays a declared label. Both the
|
|
483
|
+
multi-value branch and the chip badge already resolved it; a single value
|
|
484
|
+
did not, so the same filter read "is technical_decider" or "is any of
|
|
485
|
+
Technical Decider" depending only on the operator. */
|
|
486
|
+
describe('formatValue — enum labels', () => {
|
|
487
|
+
const roleField: FilterField = {
|
|
488
|
+
path: 'buying_role',
|
|
489
|
+
label: 'Buying Role',
|
|
490
|
+
shortLabel: 'Buying Role',
|
|
491
|
+
type: 'enum',
|
|
492
|
+
options: [
|
|
493
|
+
{ value: 'technical_decider', label: 'Technical Decider' },
|
|
494
|
+
{ value: 'economic_decider', label: 'Economic Decider' },
|
|
495
|
+
],
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
it('renders the label for a single value', () => {
|
|
499
|
+
expect(formatValue(roleField, 'eq', 'technical_decider')).toBe('Technical Decider')
|
|
500
|
+
expect(formatValue(roleField, 'ne', 'economic_decider')).toBe('Economic Decider')
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('renders labels for a multi-value operator', () => {
|
|
504
|
+
expect(formatValue(roleField, 'in', ['technical_decider', 'economic_decider'])).toBe(
|
|
505
|
+
'Technical Decider, Economic Decider',
|
|
506
|
+
)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// A value the entity no longer declares still has to render as something.
|
|
510
|
+
it('falls back to the stored value when no option matches', () => {
|
|
511
|
+
expect(formatValue(roleField, 'eq', 'retired_role')).toBe('retired_role')
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('leaves non-enum fields alone', () => {
|
|
515
|
+
const nameField: FilterField = {
|
|
516
|
+
path: 'name',
|
|
517
|
+
label: 'Name',
|
|
518
|
+
shortLabel: 'Name',
|
|
519
|
+
type: 'string',
|
|
520
|
+
}
|
|
521
|
+
expect(formatValue(nameField, 'eq', 'technical_decider')).toBe('technical_decider')
|
|
522
|
+
})
|
|
523
|
+
})
|
|
@@ -34,7 +34,8 @@ export type FilterFieldOption = {
|
|
|
34
34
|
export type FilterField = {
|
|
35
35
|
/** Dot-path to the attribute, e.g. `'owner.email'`. Stable identifier. */
|
|
36
36
|
path: string
|
|
37
|
-
/** Full nested label, e.g. `'Owner
|
|
37
|
+
/** Full nested label, e.g. `'Owner · Email'` — parts joined with
|
|
38
|
+
* `FIELD_LABEL_SEPARATOR`. Used in pickers. */
|
|
38
39
|
label: string
|
|
39
40
|
/** Leaf label only, e.g. `'Email'`. Used in chips. */
|
|
40
41
|
shortLabel: string
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export * from './components/message-composer'
|
|
|
33
33
|
export * from './components/modal'
|
|
34
34
|
export * from './components/multi-select'
|
|
35
35
|
export * from './components/nav'
|
|
36
|
+
export * from './components/orb'
|
|
36
37
|
export * from './components/popover'
|
|
37
38
|
export * from './components/progress-bar'
|
|
38
39
|
export * from './components/radio-group'
|
package/src/styles.css
CHANGED
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
/* Status pop — one-shot scale+fade entrance for the large success/error
|
|
123
123
|
status icons. Soft ease, no overshoot (nothing bounces). */
|
|
124
124
|
--animate-status-pop: status-pop var(--duration-slow) var(--ease-out-soft) both;
|
|
125
|
+
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
@keyframes indeterminate-progress {
|