@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.
Files changed (116) hide show
  1. package/dist/bodymap.js +232 -185
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +232 -185
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +164 -8
  6. package/dist/index.d.ts +164 -8
  7. package/dist/index.js +835 -342
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +816 -343
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/pages.js +570 -522
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +570 -522
  14. package/dist/pages.mjs.map +1 -1
  15. package/dist/theme/index.d.mts +461 -199
  16. package/dist/theme/index.d.ts +461 -199
  17. package/dist/theme/index.js +311 -97
  18. package/dist/theme/index.js.map +1 -1
  19. package/dist/theme/index.mjs +303 -98
  20. package/dist/theme/index.mjs.map +1 -1
  21. package/dist/theme/tokens-css.js +148 -139
  22. package/dist/theme/tokens-css.js.map +1 -1
  23. package/dist/theme/tokens-css.mjs +148 -139
  24. package/dist/theme/tokens-css.mjs.map +1 -1
  25. package/package.json +3 -1
  26. package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
  27. package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
  28. package/src/components/custom/DateTime/DateTime.tsx +55 -15
  29. package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
  30. package/src/components/custom/Gauge/Gauge.tsx +6 -3
  31. package/src/components/custom/Scatter/Scatter.tsx +3 -5
  32. package/src/components/custom/Treemap/Treemap.tsx +3 -5
  33. package/src/components/custom/Typography/Typography.stories.tsx +45 -27
  34. package/src/components/custom/Typography/Typography.test.tsx +13 -3
  35. package/src/components/custom/Typography/Typography.tsx +5 -0
  36. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
  37. package/src/components/custom/Workout/BodyMap.tsx +6 -3
  38. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
  39. package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
  40. package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
  41. package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
  42. package/src/components/custom/Workout/DeviationBar.tsx +7 -4
  43. package/src/components/custom/Workout/ExerciseDetailPage.tsx +2 -1
  44. package/src/components/custom/Workout/InputBar.tsx +2 -1
  45. package/src/components/custom/Workout/IntensityBar.test.tsx +8 -8
  46. package/src/components/custom/Workout/IntensityBar.tsx +10 -6
  47. package/src/components/custom/Workout/MesoCard.tsx +8 -3
  48. package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
  49. package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
  50. package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
  51. package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
  52. package/src/components/custom/Workout/PrBadge.tsx +5 -2
  53. package/src/components/custom/Workout/PrHistoryModal.tsx +4 -3
  54. package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
  55. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
  56. package/src/components/custom/Workout/RestTimer.tsx +2 -1
  57. package/src/components/custom/Workout/SetRow.tsx +2 -1
  58. package/src/components/custom/Workout/Sparkline.tsx +2 -1
  59. package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
  60. package/src/components/custom/Workout/StatusDot.tsx +12 -9
  61. package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
  62. package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
  63. package/src/components/custom/Workout/TempoDisplay.tsx +6 -3
  64. package/src/components/custom/Workout/VelocityStrip.test.tsx +3 -3
  65. package/src/components/custom/Workout/WeekRow.tsx +3 -2
  66. package/src/components/custom/Workout/WeightBadge.tsx +6 -2
  67. package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
  68. package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
  69. package/src/components/custom/Workout/icons.tsx +6 -67
  70. package/src/components/icons/SvgIcon.tsx +50 -0
  71. package/src/components/icons/icons.stories.tsx +57 -0
  72. package/src/components/icons/icons.test.tsx +43 -0
  73. package/src/components/icons/icons.tsx +45 -0
  74. package/src/components/icons/index.ts +4 -0
  75. package/src/components/index.ts +6 -0
  76. package/src/components/shell/BrandLockup.stories.tsx +27 -0
  77. package/src/components/shell/BrandLockup.test.tsx +23 -0
  78. package/src/components/shell/BrandLockup.tsx +47 -0
  79. package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
  80. package/src/components/shell/DeviceIndicator.test.tsx +26 -0
  81. package/src/components/shell/DeviceIndicator.tsx +52 -0
  82. package/src/components/shell/DeviceMenu.stories.tsx +47 -0
  83. package/src/components/shell/DeviceMenu.test.tsx +40 -0
  84. package/src/components/shell/DeviceMenu.tsx +65 -0
  85. package/src/components/shell/DeviceRow.stories.tsx +38 -0
  86. package/src/components/shell/DeviceRow.test.tsx +37 -0
  87. package/src/components/shell/DeviceRow.tsx +58 -0
  88. package/src/components/shell/README.md +86 -0
  89. package/src/components/shell/SessionStatePill.stories.tsx +27 -0
  90. package/src/components/shell/SessionStatePill.test.tsx +25 -0
  91. package/src/components/shell/SessionStatePill.tsx +46 -0
  92. package/src/components/shell/TopBar.stories.tsx +49 -0
  93. package/src/components/shell/TopBar.test.tsx +36 -0
  94. package/src/components/shell/TopBar.tsx +89 -0
  95. package/src/components/shell/index.ts +8 -0
  96. package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
  97. package/src/components/ui/indicator/Indicator.test.tsx +19 -1
  98. package/src/components/ui/indicator/Indicator.tsx +45 -6
  99. package/src/components/ui/indicator/index.ts +1 -1
  100. package/src/components/ui/spinner/Spinner.tsx +3 -2
  101. package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
  102. package/src/theme/ColorSystem.stories.tsx +323 -0
  103. package/src/theme/Colors.stories.tsx +69 -1
  104. package/src/theme/barrel.ts +1 -0
  105. package/src/theme/config.ts +16 -3
  106. package/src/theme/extracted-colors-dataviz.ts +21 -0
  107. package/src/theme/extracted-colors-ui.ts +20 -0
  108. package/src/theme/global.css +64 -52
  109. package/src/theme/gradients.test.ts +25 -0
  110. package/src/theme/gradients.ts +36 -0
  111. package/src/theme/tokens/primitives.test.ts +232 -0
  112. package/src/theme/tokens/primitives.ts +247 -0
  113. package/src/theme/tokens/semantic.ts +93 -81
  114. package/src/theme/workout-tokens.ts +22 -21
  115. package/src/utils/avatar-color.ts +3 -3
  116. 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
+ }
@@ -0,0 +1,4 @@
1
+ // Icon primitives — the shared, design-system-wide icon set. A base `SvgIcon`
2
+ // wrapper (24×24 web SVG + a11y contract + currentColor) and the icon glyphs.
3
+ export { SvgIcon, type IconProps, type SvgIconProps } from './SvgIcon'
4
+ export * from './icons'
@@ -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
+ })