@titan-design/react-ui 0.5.0 → 0.6.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/bodymap.js +232 -185
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +232 -185
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +164 -8
- package/dist/index.d.ts +164 -8
- package/dist/index.js +835 -342
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +816 -343
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +570 -522
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +570 -522
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +461 -199
- package/dist/theme/index.d.ts +461 -199
- package/dist/theme/index.js +311 -97
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +303 -98
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +148 -139
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +148 -139
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +3 -1
- package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
- package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
- package/src/components/custom/DateTime/DateTime.tsx +55 -15
- package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
- package/src/components/custom/Gauge/Gauge.tsx +6 -3
- package/src/components/custom/Scatter/Scatter.tsx +3 -5
- package/src/components/custom/Treemap/Treemap.tsx +3 -5
- package/src/components/custom/Typography/Typography.stories.tsx +45 -27
- package/src/components/custom/Typography/Typography.test.tsx +13 -3
- package/src/components/custom/Typography/Typography.tsx +5 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
- package/src/components/custom/Workout/BodyMap.tsx +6 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
- package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
- package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
- package/src/components/custom/Workout/DeviationBar.tsx +7 -4
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +2 -1
- package/src/components/custom/Workout/InputBar.tsx +2 -1
- package/src/components/custom/Workout/IntensityBar.test.tsx +8 -8
- package/src/components/custom/Workout/IntensityBar.tsx +10 -6
- package/src/components/custom/Workout/MesoCard.tsx +8 -3
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
- package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
- package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
- package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
- package/src/components/custom/Workout/PrBadge.tsx +5 -2
- package/src/components/custom/Workout/PrHistoryModal.tsx +4 -3
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
- package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
- package/src/components/custom/Workout/RestTimer.tsx +2 -1
- package/src/components/custom/Workout/SetRow.tsx +2 -1
- package/src/components/custom/Workout/Sparkline.tsx +2 -1
- package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
- package/src/components/custom/Workout/StatusDot.tsx +12 -9
- package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
- package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +6 -3
- package/src/components/custom/Workout/VelocityStrip.test.tsx +3 -3
- package/src/components/custom/Workout/WeekRow.tsx +3 -2
- package/src/components/custom/Workout/WeightBadge.tsx +6 -2
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
- package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
- package/src/components/custom/Workout/icons.tsx +6 -67
- package/src/components/icons/SvgIcon.tsx +50 -0
- package/src/components/icons/icons.stories.tsx +57 -0
- package/src/components/icons/icons.test.tsx +43 -0
- package/src/components/icons/icons.tsx +45 -0
- package/src/components/icons/index.ts +4 -0
- package/src/components/index.ts +6 -0
- package/src/components/shell/BrandLockup.stories.tsx +27 -0
- package/src/components/shell/BrandLockup.test.tsx +23 -0
- package/src/components/shell/BrandLockup.tsx +47 -0
- package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
- package/src/components/shell/DeviceIndicator.test.tsx +26 -0
- package/src/components/shell/DeviceIndicator.tsx +52 -0
- package/src/components/shell/DeviceMenu.stories.tsx +47 -0
- package/src/components/shell/DeviceMenu.test.tsx +40 -0
- package/src/components/shell/DeviceMenu.tsx +65 -0
- package/src/components/shell/DeviceRow.stories.tsx +38 -0
- package/src/components/shell/DeviceRow.test.tsx +37 -0
- package/src/components/shell/DeviceRow.tsx +58 -0
- package/src/components/shell/README.md +86 -0
- package/src/components/shell/SessionStatePill.stories.tsx +27 -0
- package/src/components/shell/SessionStatePill.test.tsx +25 -0
- package/src/components/shell/SessionStatePill.tsx +46 -0
- package/src/components/shell/TopBar.stories.tsx +49 -0
- package/src/components/shell/TopBar.test.tsx +36 -0
- package/src/components/shell/TopBar.tsx +89 -0
- package/src/components/shell/index.ts +8 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
- package/src/components/ui/indicator/Indicator.test.tsx +19 -1
- package/src/components/ui/indicator/Indicator.tsx +45 -6
- package/src/components/ui/indicator/index.ts +1 -1
- package/src/components/ui/spinner/Spinner.tsx +3 -2
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
- package/src/theme/ColorSystem.stories.tsx +323 -0
- package/src/theme/Colors.stories.tsx +69 -1
- package/src/theme/barrel.ts +1 -0
- package/src/theme/config.ts +16 -3
- package/src/theme/extracted-colors-dataviz.ts +21 -0
- package/src/theme/extracted-colors-ui.ts +20 -0
- package/src/theme/global.css +64 -52
- package/src/theme/gradients.test.ts +25 -0
- package/src/theme/gradients.ts +36 -0
- package/src/theme/tokens/primitives.test.ts +232 -0
- package/src/theme/tokens/primitives.ts +247 -0
- package/src/theme/tokens/semantic.ts +93 -81
- package/src/theme/workout-tokens.ts +22 -21
- package/src/utils/avatar-color.ts +3 -3
- package/tailwind.config.js +10 -3
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface IconProps {
|
|
4
|
+
/** Rendered width/height in px (viewBox is fixed 24×24). */
|
|
5
|
+
size?: number
|
|
6
|
+
/** Stroke color (or fill, for solid icons). Defaults to `currentColor` so a wrapper's text token drives it. */
|
|
7
|
+
color?: string
|
|
8
|
+
/** Stroke width for outline icons. */
|
|
9
|
+
strokeWidth?: number
|
|
10
|
+
/** Fill color; defaults to `none` (outline). */
|
|
11
|
+
fill?: string
|
|
12
|
+
/** Accessible title — sets `role="img"` + `<title>`. Omit for a decorative icon (hidden from AT). */
|
|
13
|
+
title?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SvgIconProps extends IconProps {
|
|
17
|
+
children: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Base SVG wrapper for the titan icon set — a 24×24 web-`<svg>` with the shared
|
|
22
|
+
* accessibility contract (titled → `role="img"` + `<title>`; untitled →
|
|
23
|
+
* `aria-hidden`) and `currentColor` inheritance. Icons pass their paths as
|
|
24
|
+
* children plus their stroke/fill defaults.
|
|
25
|
+
*/
|
|
26
|
+
export function SvgIcon({
|
|
27
|
+
size = 24,
|
|
28
|
+
color = 'currentColor',
|
|
29
|
+
strokeWidth = 2,
|
|
30
|
+
fill = 'none',
|
|
31
|
+
title,
|
|
32
|
+
children,
|
|
33
|
+
}: SvgIconProps) {
|
|
34
|
+
return (
|
|
35
|
+
<svg
|
|
36
|
+
width={size}
|
|
37
|
+
height={size}
|
|
38
|
+
viewBox="0 0 24 24"
|
|
39
|
+
fill={fill}
|
|
40
|
+
stroke={color}
|
|
41
|
+
strokeWidth={strokeWidth}
|
|
42
|
+
strokeLinecap="round"
|
|
43
|
+
strokeLinejoin="round"
|
|
44
|
+
{...(title ? { role: 'img', 'aria-label': title } : { 'aria-hidden': true })}
|
|
45
|
+
>
|
|
46
|
+
{title ? <title>{title}</title> : null}
|
|
47
|
+
{children}
|
|
48
|
+
</svg>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { View, Text } from 'react-native'
|
|
4
|
+
import { VoltrasMark, BluetoothIcon, DumbbellIcon, StarIcon } from './icons'
|
|
5
|
+
|
|
6
|
+
const meta: Meta = {
|
|
7
|
+
title: 'Icons',
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'The shared icon primitive — 24×24 web-`<svg>` glyphs on a common `SvgIcon` base ' +
|
|
14
|
+
'(a11y contract: titled → `role="img"`, untitled → `aria-hidden`; `currentColor` inheritance). ' +
|
|
15
|
+
'Reusable design-system-wide (the shell, Workout badges, etc. compose these).',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
export default meta
|
|
21
|
+
type Story = StoryObj
|
|
22
|
+
|
|
23
|
+
function Swatch({ label, children }: { label: string; children: ReactNode }) {
|
|
24
|
+
return (
|
|
25
|
+
<View className="items-center gap-2">
|
|
26
|
+
{children}
|
|
27
|
+
<Text className="font-mono text-[10px] text-text-tertiary">{label}</Text>
|
|
28
|
+
</View>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const All: Story = {
|
|
33
|
+
render: () => (
|
|
34
|
+
<View className="flex-row gap-8 p-4 items-start">
|
|
35
|
+
<Swatch label="VoltrasMark">
|
|
36
|
+
<View className="text-brand-primary">
|
|
37
|
+
<VoltrasMark size={28} />
|
|
38
|
+
</View>
|
|
39
|
+
</Swatch>
|
|
40
|
+
<Swatch label="BluetoothIcon">
|
|
41
|
+
<View className="text-status-success">
|
|
42
|
+
<BluetoothIcon size={28} />
|
|
43
|
+
</View>
|
|
44
|
+
</Swatch>
|
|
45
|
+
<Swatch label="DumbbellIcon">
|
|
46
|
+
<View className="text-text-primary">
|
|
47
|
+
<DumbbellIcon size={28} />
|
|
48
|
+
</View>
|
|
49
|
+
</Swatch>
|
|
50
|
+
<Swatch label="StarIcon (fill)">
|
|
51
|
+
<View className="text-brand-primary">
|
|
52
|
+
<StarIcon size={28} fill="currentColor" />
|
|
53
|
+
</View>
|
|
54
|
+
</Swatch>
|
|
55
|
+
</View>
|
|
56
|
+
),
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { VoltrasMark, BluetoothIcon, DumbbellIcon, StarIcon } from './icons'
|
|
5
|
+
import { SvgIcon } from './SvgIcon'
|
|
6
|
+
|
|
7
|
+
describe('icon primitives', () => {
|
|
8
|
+
it('every icon renders an svg', () => {
|
|
9
|
+
;[VoltrasMark, BluetoothIcon, DumbbellIcon, StarIcon].forEach((Icon) => {
|
|
10
|
+
const { container, unmount } = render(<Icon />)
|
|
11
|
+
expect(container.querySelector('svg')).toBeInTheDocument()
|
|
12
|
+
unmount()
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('a titled icon is exposed as an accessible image', () => {
|
|
17
|
+
render(<BluetoothIcon title="Devices" />)
|
|
18
|
+
expect(screen.getByRole('img', { name: 'Devices' })).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('an untitled icon is decorative (aria-hidden, no role)', () => {
|
|
22
|
+
const { container } = render(<VoltrasMark />)
|
|
23
|
+
const svg = container.querySelector('svg')!
|
|
24
|
+
expect(svg.getAttribute('aria-hidden')).toBe('true')
|
|
25
|
+
expect(svg.getAttribute('role')).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('honors size + strokeWidth via the SvgIcon base', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<SvgIcon size={32} strokeWidth={3}>
|
|
31
|
+
<path d="M0 0" />
|
|
32
|
+
</SvgIcon>
|
|
33
|
+
)
|
|
34
|
+
const svg = container.querySelector('svg')!
|
|
35
|
+
expect(svg.getAttribute('width')).toBe('32')
|
|
36
|
+
expect(svg.getAttribute('stroke-width')).toBe('3')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('a decorative icon has no accessibility violations', async () => {
|
|
40
|
+
const { container } = render(<StarIcon />)
|
|
41
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SvgIcon, type IconProps } from './SvgIcon'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Voltras brand mark — the ◇ diamond (outline). Pass `fill` for a solid mark.
|
|
5
|
+
*/
|
|
6
|
+
export function VoltrasMark({ size = 14, ...props }: IconProps) {
|
|
7
|
+
return (
|
|
8
|
+
<SvgIcon size={size} {...props}>
|
|
9
|
+
<path d="M12 2 22 12 12 22 2 12Z" />
|
|
10
|
+
</SvgIcon>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bluetooth glyph (stroked) — e.g. device-connection state (color via `currentColor`).
|
|
16
|
+
*/
|
|
17
|
+
export function BluetoothIcon({ size = 18, ...props }: IconProps) {
|
|
18
|
+
return (
|
|
19
|
+
<SvgIcon size={size} {...props}>
|
|
20
|
+
<path d="M7 7 17 17 12 22 12 2 17 7 7 17" />
|
|
21
|
+
</SvgIcon>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Dumbbell glyph (mirrors lucide-react `Dumbbell`). Outline-only by default. */
|
|
26
|
+
export function DumbbellIcon(props: IconProps) {
|
|
27
|
+
return (
|
|
28
|
+
<SvgIcon {...props}>
|
|
29
|
+
<path d="M14.4 14.4 9.6 9.6" />
|
|
30
|
+
<path d="M18.657 21.485a2 2 0 1 1-2.829-2.828l-1.767 1.768a2 2 0 1 1-2.829-2.829l6.364-6.364a2 2 0 1 1 2.829 2.829l-1.768 1.767a2 2 0 1 1 2.828 2.829z" />
|
|
31
|
+
<path d="m21.5 21.5-1.4-1.4" />
|
|
32
|
+
<path d="M3.9 3.9 2.5 2.5" />
|
|
33
|
+
<path d="M6.404 12.768a2 2 0 1 1-2.829-2.829l1.768-1.767a2 2 0 1 1-2.828-2.829l2.828-2.828a2 2 0 1 1 2.829 2.828l1.767-1.768a2 2 0 1 1 2.829 2.829z" />
|
|
34
|
+
</SvgIcon>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Star glyph (mirrors lucide-react `Star`). Pass `fill` for a solid star. */
|
|
39
|
+
export function StarIcon(props: IconProps) {
|
|
40
|
+
return (
|
|
41
|
+
<SvgIcon {...props}>
|
|
42
|
+
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
|
43
|
+
</SvgIcon>
|
|
44
|
+
)
|
|
45
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
// Icon primitives (shared, design-system-wide)
|
|
2
|
+
export * from './icons'
|
|
3
|
+
|
|
1
4
|
// Re-export all UI components
|
|
2
5
|
export * from './ui'
|
|
3
6
|
|
|
4
7
|
// Re-export custom components
|
|
5
8
|
export * from './custom'
|
|
9
|
+
|
|
10
|
+
// Re-export shell chrome components
|
|
11
|
+
export * from './shell'
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { BrandLockup } from './BrandLockup'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof BrandLockup> = {
|
|
5
|
+
title: 'Shell/TopBar/BrandLockup',
|
|
6
|
+
component: BrandLockup,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
args: { subtitle: 'wall dashboard', showSubtitle: true },
|
|
9
|
+
argTypes: {
|
|
10
|
+
subtitle: { control: 'text' },
|
|
11
|
+
showSubtitle: { control: 'boolean' },
|
|
12
|
+
},
|
|
13
|
+
parameters: {
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component:
|
|
17
|
+
'**Molecule.** Composes [VoltrasMark](?path=/docs/icons--docs) (icon) + ' +
|
|
18
|
+
'[Typography](?path=/docs/custom-typography--docs) (wordmark + subtitle). ' +
|
|
19
|
+
'Toggle `showSubtitle` to see the responsive collapse.',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
export default meta
|
|
25
|
+
type Story = StoryObj<typeof BrandLockup>
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { BrandLockup } from './BrandLockup'
|
|
5
|
+
|
|
6
|
+
describe('BrandLockup', () => {
|
|
7
|
+
it('renders the wordmark', () => {
|
|
8
|
+
render(<BrandLockup />)
|
|
9
|
+
expect(screen.getByText('VOLTRAS')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows the subtitle by default and hides it when showSubtitle is false', () => {
|
|
13
|
+
const { rerender } = render(<BrandLockup subtitle="wall dashboard" />)
|
|
14
|
+
expect(screen.getByText('/ wall dashboard')).toBeInTheDocument()
|
|
15
|
+
rerender(<BrandLockup subtitle="wall dashboard" showSubtitle={false} />)
|
|
16
|
+
expect(screen.queryByText('/ wall dashboard')).not.toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('has no accessibility violations', async () => {
|
|
20
|
+
const { container } = render(<BrandLockup />)
|
|
21
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { View, type ViewProps } from 'react-native'
|
|
2
|
+
import { cn } from '../../utils/cn'
|
|
3
|
+
import { Typography } from '../custom/Typography'
|
|
4
|
+
import { VoltrasMark } from '../icons'
|
|
5
|
+
|
|
6
|
+
export interface BrandLockupProps extends ViewProps {
|
|
7
|
+
/** The "/ subtitle" that follows the wordmark. Default "wall dashboard". */
|
|
8
|
+
subtitle?: string
|
|
9
|
+
/** Show the subtitle. Hidden at tablet width and below (S1-Q4 responsive). */
|
|
10
|
+
showSubtitle?: boolean
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Product identity lockup for the top bar: ◇ mark + VOLTRAS wordmark + optional
|
|
16
|
+
* subtitle. Composes the VoltrasMark icon + Typography. S1 · BrandLockup.
|
|
17
|
+
*/
|
|
18
|
+
export function BrandLockup({
|
|
19
|
+
subtitle = 'wall dashboard',
|
|
20
|
+
showSubtitle = true,
|
|
21
|
+
className,
|
|
22
|
+
...props
|
|
23
|
+
}: BrandLockupProps) {
|
|
24
|
+
return (
|
|
25
|
+
<View className={cn('flex-row items-center gap-[7px]', className)} {...props}>
|
|
26
|
+
<View className="text-brand-primary">
|
|
27
|
+
<VoltrasMark size={14} color="currentColor" />
|
|
28
|
+
</View>
|
|
29
|
+
<Typography
|
|
30
|
+
variant="button"
|
|
31
|
+
color="primary"
|
|
32
|
+
className="text-[12px] font-extrabold tracking-[1.5px]"
|
|
33
|
+
>
|
|
34
|
+
VOLTRAS
|
|
35
|
+
</Typography>
|
|
36
|
+
{showSubtitle && subtitle ? (
|
|
37
|
+
<Typography
|
|
38
|
+
variant="button"
|
|
39
|
+
color="tertiary"
|
|
40
|
+
className="text-[10px] font-semibold tracking-[0.4px]"
|
|
41
|
+
>
|
|
42
|
+
/ {subtitle}
|
|
43
|
+
</Typography>
|
|
44
|
+
) : null}
|
|
45
|
+
</View>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { DeviceIndicator } from './DeviceIndicator'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof DeviceIndicator> = {
|
|
5
|
+
title: 'Shell/TopBar/DeviceMenu/DeviceIndicator',
|
|
6
|
+
component: DeviceIndicator,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
args: { status: 'connected' },
|
|
9
|
+
argTypes: {
|
|
10
|
+
status: { control: 'select', options: ['connected', 'degraded', 'lost'] },
|
|
11
|
+
label: { control: 'text' },
|
|
12
|
+
onPress: { control: false },
|
|
13
|
+
},
|
|
14
|
+
parameters: {
|
|
15
|
+
docs: {
|
|
16
|
+
description: {
|
|
17
|
+
component:
|
|
18
|
+
'**Molecule.** Composes [BluetoothIcon](?path=/docs/icons--docs), color-coded by ' +
|
|
19
|
+
'connection state via a `text-status-*-vivid` token — the glyph color alone carries the fault ' +
|
|
20
|
+
'(no separate badge). Switch the `status` control to see connected / degraded / lost.',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
export default meta
|
|
26
|
+
type Story = StoryObj<typeof DeviceIndicator>
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { DeviceIndicator } from './DeviceIndicator'
|
|
4
|
+
|
|
5
|
+
describe('DeviceIndicator', () => {
|
|
6
|
+
it('renders each connection state', () => {
|
|
7
|
+
const states = ['connected', 'degraded', 'lost'] as const
|
|
8
|
+
states.forEach((status) => {
|
|
9
|
+
const { unmount, container } = render(<DeviceIndicator status={status} />)
|
|
10
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
11
|
+
unmount()
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('exposes a button role and fires onPress when interactive', () => {
|
|
16
|
+
const onPress = vi.fn()
|
|
17
|
+
render(<DeviceIndicator onPress={onPress} />)
|
|
18
|
+
fireEvent.click(screen.getByRole('button', { name: 'Devices' }))
|
|
19
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders the lost state (red glyph, no separate badge)', () => {
|
|
23
|
+
const { container } = render(<DeviceIndicator status="lost" />)
|
|
24
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { View, Pressable, type ViewProps } from 'react-native'
|
|
2
|
+
import { cn } from '../../utils/cn'
|
|
3
|
+
import { BluetoothIcon } from '../icons'
|
|
4
|
+
|
|
5
|
+
export type DeviceConnState = 'connected' | 'degraded' | 'lost'
|
|
6
|
+
|
|
7
|
+
export interface DeviceIndicatorProps extends ViewProps {
|
|
8
|
+
/** Aggregate connection state (worst-of the bound devices) → glyph color. */
|
|
9
|
+
status?: DeviceConnState
|
|
10
|
+
/** Tap handler. When omitted the glyph is non-interactive (e.g. inside a PopoverTrigger). */
|
|
11
|
+
onPress?: () => void
|
|
12
|
+
/** Accessible label (names the glyph / the wrapping trigger button). */
|
|
13
|
+
label?: string
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// currentColor drives the stroke → color the wrapper's text token by state (vivid, matches LIVE).
|
|
18
|
+
// The glyph color alone carries the fault (red on lost) — no separate alert badge.
|
|
19
|
+
const statusTextClass: Record<DeviceConnState, string> = {
|
|
20
|
+
connected: 'text-status-success',
|
|
21
|
+
degraded: 'text-status-warning',
|
|
22
|
+
lost: 'text-status-error-vivid',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The device/BLE connection glyph — a color-coded bluetooth mark, no label.
|
|
27
|
+
* Opens the DeviceMenu when tapped. S1 · DeviceIndicator.
|
|
28
|
+
*/
|
|
29
|
+
export function DeviceIndicator({
|
|
30
|
+
status = 'connected',
|
|
31
|
+
onPress,
|
|
32
|
+
label = 'Devices',
|
|
33
|
+
className,
|
|
34
|
+
...props
|
|
35
|
+
}: DeviceIndicatorProps) {
|
|
36
|
+
const Wrapper = onPress ? Pressable : View
|
|
37
|
+
return (
|
|
38
|
+
<Wrapper
|
|
39
|
+
accessibilityLabel={label}
|
|
40
|
+
{...(onPress ? { onPress, accessibilityRole: 'button' } : {})}
|
|
41
|
+
className={cn(
|
|
42
|
+
'w-[26px] h-[28px] items-center justify-center rounded-[7px]',
|
|
43
|
+
statusTextClass[status],
|
|
44
|
+
className
|
|
45
|
+
)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{/* the SVG title names the wrapping trigger button (RNW drops aria-label on non-accessible Views) */}
|
|
49
|
+
<BluetoothIcon size={18} color="currentColor" title={label} />
|
|
50
|
+
</Wrapper>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { DeviceMenu } from './DeviceMenu'
|
|
4
|
+
import { type Device } from './DeviceRow'
|
|
5
|
+
|
|
6
|
+
const DEVICES: Device[] = [
|
|
7
|
+
{ id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
|
|
8
|
+
{ id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
|
|
9
|
+
{ id: 'Voltra-77E0', nickname: 'Spare', slot: null, state: 'available' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const meta: Meta<typeof DeviceMenu> = {
|
|
13
|
+
title: 'Shell/TopBar/DeviceMenu',
|
|
14
|
+
component: DeviceMenu,
|
|
15
|
+
tags: ['autodocs'],
|
|
16
|
+
args: { devices: DEVICES, isOpen: false },
|
|
17
|
+
argTypes: {
|
|
18
|
+
devices: { control: 'object' },
|
|
19
|
+
isOpen: { control: 'boolean' },
|
|
20
|
+
onOpenChange: { control: false },
|
|
21
|
+
onSelectDevice: { control: false },
|
|
22
|
+
},
|
|
23
|
+
decorators: [
|
|
24
|
+
(Story) => (
|
|
25
|
+
<View className="flex-row p-4 pb-48">
|
|
26
|
+
<Story />
|
|
27
|
+
</View>
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
parameters: {
|
|
31
|
+
docs: {
|
|
32
|
+
description: {
|
|
33
|
+
component:
|
|
34
|
+
'**Organism.** The device glyph + its dropdown. Composes ' +
|
|
35
|
+
'[Popover](?path=/docs/components-popover--docs) + ' +
|
|
36
|
+
'[DeviceIndicator](?path=/docs/shell-topbar-devicemenu-deviceindicator--docs) (trigger) + ' +
|
|
37
|
+
'[DeviceRow](?path=/docs/shell-topbar-devicemenu-devicerow--docs) (list) + ' +
|
|
38
|
+
'[Typography](?path=/docs/custom-typography--docs) (header). Aggregates the bound devices ' +
|
|
39
|
+
'(worst-of) for the glyph state. Click the glyph, or toggle the `isOpen` control.',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
export default meta
|
|
45
|
+
type Story = StoryObj<typeof DeviceMenu>
|
|
46
|
+
|
|
47
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { DeviceMenu } from './DeviceMenu'
|
|
4
|
+
import { type Device } from './DeviceRow'
|
|
5
|
+
|
|
6
|
+
const devices: Device[] = [
|
|
7
|
+
{ id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
|
|
8
|
+
{ id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
|
|
9
|
+
{ id: 'Voltra-77E0', nickname: 'Spare', slot: null, state: 'available' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('DeviceMenu', () => {
|
|
13
|
+
it('renders the trigger glyph and opens the device list on click', () => {
|
|
14
|
+
render(<DeviceMenu devices={devices} />)
|
|
15
|
+
const trigger = screen.getByRole('button', { name: 'Devices' })
|
|
16
|
+
expect(screen.queryByText('Left Cable')).not.toBeInTheDocument()
|
|
17
|
+
fireEvent.click(trigger)
|
|
18
|
+
expect(screen.getByText('Left Cable')).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByText(/2 bound · 1 available/)).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('reports a controlled open state', () => {
|
|
23
|
+
render(<DeviceMenu devices={devices} isOpen />)
|
|
24
|
+
expect(screen.getByText('Right Cable')).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('fires onSelectDevice when a row is tapped', () => {
|
|
28
|
+
const onSelectDevice = vi.fn()
|
|
29
|
+
render(<DeviceMenu devices={devices} isOpen onSelectDevice={onSelectDevice} />)
|
|
30
|
+
fireEvent.click(screen.getByText('Left Cable'))
|
|
31
|
+
expect(onSelectDevice).toHaveBeenCalledWith(expect.objectContaining({ id: 'Voltra-A3F2' }))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders regardless of the aggregate worst-of state (degraded / lost bound device)', () => {
|
|
35
|
+
const degraded: Device[] = [{ id: 'D', nickname: 'Left', slot: 'L', state: 'degraded' }]
|
|
36
|
+
const lost: Device[] = [{ id: 'L', nickname: 'Left', slot: 'L', state: 'lost' }]
|
|
37
|
+
expect(render(<DeviceMenu devices={degraded} />).container.firstChild).toBeInTheDocument()
|
|
38
|
+
expect(render(<DeviceMenu devices={lost} />).container.firstChild).toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { View } from 'react-native'
|
|
2
|
+
import { cn } from '../../utils/cn'
|
|
3
|
+
import { Typography } from '../custom/Typography'
|
|
4
|
+
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover'
|
|
5
|
+
import { DeviceIndicator, type DeviceConnState } from './DeviceIndicator'
|
|
6
|
+
import { DeviceRow, type Device } from './DeviceRow'
|
|
7
|
+
|
|
8
|
+
export interface DeviceMenuProps {
|
|
9
|
+
devices: Device[]
|
|
10
|
+
/** Controlled open state (optional — uncontrolled otherwise). */
|
|
11
|
+
isOpen?: boolean
|
|
12
|
+
onOpenChange?: (open: boolean) => void
|
|
13
|
+
onSelectDevice?: (device: Device) => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Aggregate the bound devices into a single glyph state (worst-of). */
|
|
18
|
+
function aggregateStatus(devices: Device[]): DeviceConnState {
|
|
19
|
+
const bound = devices.filter((d) => d.slot)
|
|
20
|
+
if (bound.some((d) => d.state === 'lost')) return 'lost'
|
|
21
|
+
if (bound.some((d) => d.state === 'degraded')) return 'degraded'
|
|
22
|
+
return 'connected'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The device glyph + its dropdown of individual devices (availability, slot
|
|
27
|
+
* binding, id, connection state). Composes Popover + DeviceIndicator +
|
|
28
|
+
* DeviceRow. S1 · DeviceMenu.
|
|
29
|
+
*/
|
|
30
|
+
export function DeviceMenu({
|
|
31
|
+
devices,
|
|
32
|
+
isOpen,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
onSelectDevice,
|
|
35
|
+
className,
|
|
36
|
+
}: DeviceMenuProps) {
|
|
37
|
+
const status = aggregateStatus(devices)
|
|
38
|
+
const boundCount = devices.filter((d) => d.slot).length
|
|
39
|
+
const availCount = devices.filter((d) => !d.slot).length
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Popover placement="bottom" isOpen={isOpen} onOpenChange={onOpenChange} className={className}>
|
|
43
|
+
<PopoverTrigger>
|
|
44
|
+
<DeviceIndicator status={status} />
|
|
45
|
+
</PopoverTrigger>
|
|
46
|
+
<PopoverContent className="right-0 left-auto mt-[10px] w-[326px] bg-surface-elevated border border-border-strong rounded-[10px] p-[7px]">
|
|
47
|
+
<Typography
|
|
48
|
+
variant="monoLabel"
|
|
49
|
+
color="tertiary"
|
|
50
|
+
className="text-[9px] tracking-[0.8px] px-2 pt-[6px] pb-2"
|
|
51
|
+
>
|
|
52
|
+
Devices — {boundCount} bound · {availCount} available
|
|
53
|
+
</Typography>
|
|
54
|
+
{devices.map((device, i) => (
|
|
55
|
+
<View key={device.id} className={cn(i > 0 && 'border-t border-border')}>
|
|
56
|
+
<DeviceRow
|
|
57
|
+
device={device}
|
|
58
|
+
onPress={onSelectDevice ? () => onSelectDevice(device) : undefined}
|
|
59
|
+
/>
|
|
60
|
+
</View>
|
|
61
|
+
))}
|
|
62
|
+
</PopoverContent>
|
|
63
|
+
</Popover>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { DeviceRow } from './DeviceRow'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof DeviceRow> = {
|
|
6
|
+
title: 'Shell/TopBar/DeviceMenu/DeviceRow',
|
|
7
|
+
component: DeviceRow,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
args: {
|
|
10
|
+
device: { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
|
|
11
|
+
},
|
|
12
|
+
argTypes: {
|
|
13
|
+
device: { control: 'object' },
|
|
14
|
+
onPress: { control: false },
|
|
15
|
+
},
|
|
16
|
+
decorators: [
|
|
17
|
+
(Story) => (
|
|
18
|
+
<View className="w-[320px] bg-surface-elevated rounded-lg p-1">
|
|
19
|
+
<Story />
|
|
20
|
+
</View>
|
|
21
|
+
),
|
|
22
|
+
],
|
|
23
|
+
parameters: {
|
|
24
|
+
docs: {
|
|
25
|
+
description: {
|
|
26
|
+
component:
|
|
27
|
+
'**Molecule.** One device row: status dot · name · Bluetooth id. Composes ' +
|
|
28
|
+
'[Indicator](?path=/docs/components-indicator--docs) (status dot) + ' +
|
|
29
|
+
'[Typography](?path=/docs/custom-typography--docs) (`body2` name, `mono` id). ' +
|
|
30
|
+
'Edit the `device` control (set `state` to `lost` / `available`, `slot` to `null`) to explore.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
export default meta
|
|
36
|
+
type Story = StoryObj<typeof DeviceRow>
|
|
37
|
+
|
|
38
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { DeviceRow, type Device } from './DeviceRow'
|
|
4
|
+
|
|
5
|
+
const bound: Device = { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' }
|
|
6
|
+
const unbound: Device = { id: 'Voltra-77E0', nickname: 'Spare', slot: null, state: 'available' }
|
|
7
|
+
|
|
8
|
+
describe('DeviceRow', () => {
|
|
9
|
+
it('renders the device name and Bluetooth id', () => {
|
|
10
|
+
render(<DeviceRow device={bound} />)
|
|
11
|
+
expect(screen.getByText('Left Cable')).toBeInTheDocument()
|
|
12
|
+
expect(screen.getByText('Voltra-A3F2')).toBeInTheDocument()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('omits the redundant status text and slot pill (dot + name carry it)', () => {
|
|
16
|
+
render(<DeviceRow device={bound} />)
|
|
17
|
+
expect(screen.queryByText('connected')).not.toBeInTheDocument()
|
|
18
|
+
expect(screen.queryByText('SLOT L')).not.toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders an unbound device by name', () => {
|
|
22
|
+
render(<DeviceRow device={unbound} />)
|
|
23
|
+
expect(screen.getByText('Spare')).toBeInTheDocument()
|
|
24
|
+
expect(screen.getByText('Voltra-77E0')).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('renders every connection state without crashing', () => {
|
|
28
|
+
const states = ['connected', 'available', 'degraded', 'lost'] as const
|
|
29
|
+
states.forEach((state) => {
|
|
30
|
+
const { unmount, container } = render(
|
|
31
|
+
<DeviceRow device={{ id: 'X', nickname: 'Cable', slot: 'L', state }} />
|
|
32
|
+
)
|
|
33
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
34
|
+
unmount()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
})
|