@titan-design/react-ui 0.2.6 → 0.2.7
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/chunk-3VLOBS6O.mjs +846 -0
- package/dist/chunk-3VLOBS6O.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
- package/dist/chunk-5SSKGS6E.mjs.map +1 -0
- package/dist/index.d.mts +1005 -4
- package/dist/index.d.ts +1005 -4
- package/dist/index.js +6043 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5901 -177
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +57 -1
- package/dist/theme/index.d.ts +57 -1
- package/dist/theme/index.js +63 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +18 -0
- package/dist/theme/tokens-css.d.ts +18 -0
- package/dist/theme/tokens-css.js +289 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/package.json +16 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +348 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +163 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +235 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
- package/src/components/custom/Workout/RestTimer.tsx +8 -6
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +253 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +159 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +113 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +34 -0
- package/src/theme/workout-tokens.ts +61 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -64,7 +64,7 @@ export function Sidebar({
|
|
|
64
64
|
<View
|
|
65
65
|
style={{ width: currentWidth }}
|
|
66
66
|
className={cn(
|
|
67
|
-
'h-full flex-col bg-surface-base border-r border-border
|
|
67
|
+
'h-full flex-col bg-surface-base border-r border-border transition-all duration-200',
|
|
68
68
|
className
|
|
69
69
|
)}
|
|
70
70
|
{...props}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Treemap, type TreemapDatum } from './Treemap'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Treemap> = {
|
|
5
|
+
title: 'Custom/Treemap',
|
|
6
|
+
component: Treemap,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
width: { control: { type: 'range', min: 200, max: 900, step: 20 } },
|
|
10
|
+
height: { control: { type: 'range', min: 120, max: 600, step: 20 } },
|
|
11
|
+
scale: { control: 'select', options: ['linear', 'sqrt', 'log'] },
|
|
12
|
+
maxTiles: { control: { type: 'range', min: 4, max: 80, step: 1 } },
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof Treemap>
|
|
17
|
+
|
|
18
|
+
const sample: TreemapDatum[] = [
|
|
19
|
+
{ id: 'router', value: 5035, color: '#F83030', label: 'router.ts' },
|
|
20
|
+
{ id: 'engine', value: 4378, color: '#F83030', label: 'engine.ts' },
|
|
21
|
+
{ id: 'linker', value: 1695, color: '#F4A736', label: 'linker.ts' },
|
|
22
|
+
{ id: 'validate', value: 1288, color: '#F4A736', label: 'validate.ts' },
|
|
23
|
+
{ id: 'hook', value: 1050, color: '#F4A736', label: 'hook.ts' },
|
|
24
|
+
{ id: 'update', value: 890, color: '#5B9BD5', label: 'update.ts' },
|
|
25
|
+
{ id: 'pool', value: 504, color: '#5B9BD5', label: 'pool.ts' },
|
|
26
|
+
{ id: 'log', value: 342, color: '#5B9BD5', label: 'log.ts' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: { data: sample, width: 560, height: 300, scale: 'sqrt' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single outlier under a linear scale swallows the canvas... */
|
|
34
|
+
export const OutlierLinear: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
data: [{ id: 'mega', value: 205422, color: '#F83030', label: 'mega.ts' }, ...sample],
|
|
37
|
+
width: 560,
|
|
38
|
+
height: 300,
|
|
39
|
+
scale: 'linear',
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** ...the same data with a sqrt scale keeps every tile legible. */
|
|
44
|
+
export const OutlierSqrt: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
data: [{ id: 'mega', value: 205422, color: '#F83030', label: 'mega.ts' }, ...sample],
|
|
47
|
+
width: 560,
|
|
48
|
+
height: 300,
|
|
49
|
+
scale: 'sqrt',
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const Truncated: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
data: Array.from({ length: 200 }, (_, i) => ({ id: `n${i}`, value: 200 - i })),
|
|
56
|
+
width: 560,
|
|
57
|
+
height: 300,
|
|
58
|
+
maxTiles: 40,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Treemap, type TreemapDatum } from './Treemap'
|
|
5
|
+
|
|
6
|
+
const data: TreemapDatum[] = [
|
|
7
|
+
{ id: 'a', value: 100, color: '#5B9BD5', label: 'A' },
|
|
8
|
+
{ id: 'b', value: 40, color: '#14B8A6', label: 'B' },
|
|
9
|
+
{ id: 'c', value: 10, color: '#F4A736', label: 'C' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('Treemap', () => {
|
|
13
|
+
it('renders one tile per positive datum', () => {
|
|
14
|
+
render(<Treemap data={data} width={300} height={200} />)
|
|
15
|
+
expect(screen.getByTestId('treemap-tile-a')).toBeInTheDocument()
|
|
16
|
+
expect(screen.getByTestId('treemap-tile-b')).toBeInTheDocument()
|
|
17
|
+
expect(screen.getByTestId('treemap-tile-c')).toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('drops non-positive values', () => {
|
|
21
|
+
render(<Treemap data={[...data, { id: 'zero', value: 0 }]} width={300} height={200} />)
|
|
22
|
+
expect(screen.queryByTestId('treemap-tile-zero')).not.toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('folds overflow into a single "+M more" tile', () => {
|
|
26
|
+
const many = Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, value: 10 - i * 0.5 }))
|
|
27
|
+
render(<Treemap data={many} width={300} height={200} maxTiles={4} />)
|
|
28
|
+
expect(screen.getByTestId('treemap-tile-__more__')).toBeInTheDocument()
|
|
29
|
+
// 3 explicit + 1 fold = 4 tiles
|
|
30
|
+
expect(screen.getByLabelText('+7 more')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('fires onPress with the tile id', () => {
|
|
34
|
+
const onPress = vi.fn()
|
|
35
|
+
render(<Treemap data={data} width={300} height={200} onPress={onPress} />)
|
|
36
|
+
fireEvent.click(screen.getByTestId('treemap-tile-a'))
|
|
37
|
+
expect(onPress).toHaveBeenCalledWith('a')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not fire onPress for the fold tile', () => {
|
|
41
|
+
const onPress = vi.fn()
|
|
42
|
+
const many = Array.from({ length: 6 }, (_, i) => ({ id: `n${i}`, value: 6 - i }))
|
|
43
|
+
render(<Treemap data={many} width={300} height={200} maxTiles={3} onPress={onPress} />)
|
|
44
|
+
fireEvent.click(screen.getByTestId('treemap-tile-__more__'))
|
|
45
|
+
expect(onPress).not.toHaveBeenCalled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('assigns fallback palette colors when omitted', () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<Treemap data={[{ id: 'x', value: 1 }]} width={100} height={100} />,
|
|
51
|
+
)
|
|
52
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders nothing meaningful for all-zero data without crashing', () => {
|
|
56
|
+
render(<Treemap data={[{ id: 'z', value: 0 }]} width={100} height={100} />)
|
|
57
|
+
expect(screen.queryByTestId('treemap-tile-z')).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('accessibility', () => {
|
|
61
|
+
it('has no accessibility violations', async () => {
|
|
62
|
+
const { container } = render(<Treemap data={data} width={300} height={200} />)
|
|
63
|
+
const results = await axe(container)
|
|
64
|
+
expect(results).toHaveNoViolations()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export interface TreemapDatum {
|
|
6
|
+
/** Stable identity — returned by onPress and used as the React key. */
|
|
7
|
+
id: string
|
|
8
|
+
/** Area weight (e.g. size, count, severity). Non-positive values are dropped. */
|
|
9
|
+
value: number
|
|
10
|
+
/** Tile fill color. Falls back to a built-in categorical palette by index. */
|
|
11
|
+
color?: string
|
|
12
|
+
/** Optional label rendered inside sufficiently large tiles. */
|
|
13
|
+
label?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TreemapScale = 'linear' | 'sqrt' | 'log'
|
|
17
|
+
|
|
18
|
+
export interface TreemapProps extends Omit<ViewProps, 'children'> {
|
|
19
|
+
data: TreemapDatum[]
|
|
20
|
+
/** Layout box. Required — the treemap fills exactly this area. */
|
|
21
|
+
width: number
|
|
22
|
+
height: number
|
|
23
|
+
/**
|
|
24
|
+
* Area scale. Real-world weights are often heavy-tailed, where one outlier
|
|
25
|
+
* under a `linear` scale swallows the whole canvas. `sqrt` (default) damps
|
|
26
|
+
* that while preserving order; `log` compresses hardest.
|
|
27
|
+
*/
|
|
28
|
+
scale?: TreemapScale
|
|
29
|
+
/** Show at most N tiles; the remainder collapse into one "+M more" tile. */
|
|
30
|
+
maxTiles?: number
|
|
31
|
+
/** Fires with a tile's id on press (never for the "+M more" tile). */
|
|
32
|
+
onPress?: (id: string) => void
|
|
33
|
+
/** Draws a highlight border on the matching tile. */
|
|
34
|
+
selectedId?: string
|
|
35
|
+
className?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Titan categorical fallback palette (data-1..data-8). */
|
|
39
|
+
const PALETTE = [
|
|
40
|
+
'#5B9BD5', '#14B8A6', '#F4A736', '#F83030',
|
|
41
|
+
'#823CA0', '#D4A520', '#406D87', '#43C6B7',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
interface Rect {
|
|
45
|
+
x: number
|
|
46
|
+
y: number
|
|
47
|
+
w: number
|
|
48
|
+
h: number
|
|
49
|
+
datum: TreemapDatum
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function scaleValue(v: number, scale: TreemapScale): number {
|
|
53
|
+
if (v <= 0) return 0
|
|
54
|
+
if (scale === 'sqrt') return Math.sqrt(v)
|
|
55
|
+
if (scale === 'log') return Math.log10(v + 1)
|
|
56
|
+
return v
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function worstRatio(row: { area: number }[], side: number): number {
|
|
60
|
+
const sum = row.reduce((s, r) => s + r.area, 0)
|
|
61
|
+
const thickness = sum / side
|
|
62
|
+
let worst = 0
|
|
63
|
+
for (const r of row) {
|
|
64
|
+
const len = r.area / thickness
|
|
65
|
+
const ratio = Math.max(thickness / len, len / thickness)
|
|
66
|
+
if (ratio > worst) worst = ratio
|
|
67
|
+
}
|
|
68
|
+
return worst
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Squarified treemap (Bruls, Huizing & van Wijk, 2000). */
|
|
72
|
+
function squarify(data: TreemapDatum[], w: number, h: number): Rect[] {
|
|
73
|
+
const total = data.reduce((s, d) => s + d.value, 0)
|
|
74
|
+
if (total <= 0 || w <= 0 || h <= 0) return []
|
|
75
|
+
const scaled = data.map((d) => ({ datum: d, area: (d.value / total) * w * h }))
|
|
76
|
+
const out: Rect[] = []
|
|
77
|
+
let rx = 0
|
|
78
|
+
let ry = 0
|
|
79
|
+
let rw = w
|
|
80
|
+
let rh = h
|
|
81
|
+
let i = 0
|
|
82
|
+
|
|
83
|
+
while (i < scaled.length) {
|
|
84
|
+
const vertical = rw >= rh
|
|
85
|
+
const side = vertical ? rh : rw
|
|
86
|
+
let row: typeof scaled = []
|
|
87
|
+
let bestWorst = Infinity
|
|
88
|
+
let j = i
|
|
89
|
+
while (j < scaled.length) {
|
|
90
|
+
const trial = [...row, scaled[j]]
|
|
91
|
+
const worst = worstRatio(trial, side)
|
|
92
|
+
if (row.length > 0 && worst > bestWorst) break
|
|
93
|
+
row = trial
|
|
94
|
+
bestWorst = worst
|
|
95
|
+
j++
|
|
96
|
+
}
|
|
97
|
+
const rowArea = row.reduce((s, r) => s + r.area, 0)
|
|
98
|
+
const thickness = rowArea / side
|
|
99
|
+
let along = vertical ? ry : rx
|
|
100
|
+
for (const r of row) {
|
|
101
|
+
const len = r.area / thickness
|
|
102
|
+
out.push(
|
|
103
|
+
vertical
|
|
104
|
+
? { x: rx, y: along, w: thickness, h: len, datum: r.datum }
|
|
105
|
+
: { x: along, y: ry, w: len, h: thickness, datum: r.datum },
|
|
106
|
+
)
|
|
107
|
+
along += len
|
|
108
|
+
}
|
|
109
|
+
if (vertical) {
|
|
110
|
+
rx += thickness
|
|
111
|
+
rw -= thickness
|
|
112
|
+
} else {
|
|
113
|
+
ry += thickness
|
|
114
|
+
rh -= thickness
|
|
115
|
+
}
|
|
116
|
+
i = j
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Squarified treemap. SVG-free (absolutely-positioned tiles) so it renders
|
|
123
|
+
* identically on web (react-native-web) and native. Layout is computed from
|
|
124
|
+
* `value`; color and labels are caller-supplied.
|
|
125
|
+
*/
|
|
126
|
+
export function Treemap({
|
|
127
|
+
data,
|
|
128
|
+
width,
|
|
129
|
+
height,
|
|
130
|
+
scale = 'sqrt',
|
|
131
|
+
maxTiles = 60,
|
|
132
|
+
onPress,
|
|
133
|
+
selectedId,
|
|
134
|
+
className,
|
|
135
|
+
...props
|
|
136
|
+
}: TreemapProps) {
|
|
137
|
+
const clean = data
|
|
138
|
+
.filter((d) => d.value > 0)
|
|
139
|
+
.sort((a, b) => b.value - a.value)
|
|
140
|
+
.map((d, i) => ({ ...d, color: d.color ?? PALETTE[i % PALETTE.length] }))
|
|
141
|
+
|
|
142
|
+
let tiles = clean
|
|
143
|
+
if (clean.length > maxTiles) {
|
|
144
|
+
const head = clean.slice(0, maxTiles - 1)
|
|
145
|
+
const restValue = clean.slice(maxTiles - 1).reduce((s, d) => s + d.value, 0)
|
|
146
|
+
tiles = [
|
|
147
|
+
...head,
|
|
148
|
+
{ id: '__more__', value: restValue, color: '#3a3a3a', label: `+${clean.length - (maxTiles - 1)} more` },
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const weighted = tiles.map((d) => ({ ...d, value: scaleValue(d.value, scale) }))
|
|
153
|
+
const rects = squarify(weighted, width, height)
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<View className={cn('relative', className)} style={{ width, height }} {...props}>
|
|
157
|
+
{rects.map((r) => {
|
|
158
|
+
const selected = r.datum.id === selectedId
|
|
159
|
+
const labelFits = r.w > 54 && r.h > 22
|
|
160
|
+
const pressable = r.datum.id !== '__more__'
|
|
161
|
+
return (
|
|
162
|
+
<Pressable
|
|
163
|
+
key={r.datum.id}
|
|
164
|
+
testID={`treemap-tile-${r.datum.id}`}
|
|
165
|
+
accessibilityLabel={r.datum.label ?? r.datum.id}
|
|
166
|
+
onPress={pressable ? () => onPress?.(r.datum.id) : undefined}
|
|
167
|
+
style={{
|
|
168
|
+
position: 'absolute',
|
|
169
|
+
left: r.x,
|
|
170
|
+
top: r.y,
|
|
171
|
+
width: Math.max(0, r.w - 2),
|
|
172
|
+
height: Math.max(0, r.h - 2),
|
|
173
|
+
backgroundColor: r.datum.color,
|
|
174
|
+
borderRadius: 3,
|
|
175
|
+
opacity: selected ? 1 : 0.9,
|
|
176
|
+
borderWidth: selected ? 2 : 0,
|
|
177
|
+
borderColor: '#ffffff',
|
|
178
|
+
padding: 4,
|
|
179
|
+
overflow: 'hidden',
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{labelFits && (
|
|
183
|
+
<Text numberOfLines={2} className="text-[10px] font-semibold text-[#0b0b0b]">
|
|
184
|
+
{r.datum.label ?? r.datum.id}
|
|
185
|
+
</Text>
|
|
186
|
+
)}
|
|
187
|
+
</Pressable>
|
|
188
|
+
)
|
|
189
|
+
})}
|
|
190
|
+
</View>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { Dumbbell } from 'lucide-react'
|
|
4
|
+
import { BaseBadge } from './BaseBadge'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof BaseBadge> = {
|
|
7
|
+
title: 'Custom/Workout/BaseBadge',
|
|
8
|
+
component: BaseBadge,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
variant: { control: 'select', options: ['plain', 'pr'], description: 'Visual variant' },
|
|
12
|
+
size: { control: 'select', options: ['sm', 'md', 'lg'], description: 'Badge size' },
|
|
13
|
+
onPress: { action: 'pressed', description: 'Callback when tapped' },
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof BaseBadge>
|
|
19
|
+
|
|
20
|
+
const Label = ({ children, color = '#9CA3AF' }: { children: string; color?: string }) => (
|
|
21
|
+
<Text
|
|
22
|
+
style={{
|
|
23
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
24
|
+
fontWeight: '600',
|
|
25
|
+
fontSize: 10,
|
|
26
|
+
color,
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</Text>
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export const Plain: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
variant: 'plain',
|
|
36
|
+
children: <Label>225 lbs</Label>,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const Pr: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
variant: 'pr',
|
|
43
|
+
children: <Label color="#FF7900">PR</Label>,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const WithIcon: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
variant: 'plain',
|
|
50
|
+
icon: <Dumbbell size={12} color="#9CA3AF" strokeWidth={2} />,
|
|
51
|
+
children: <Label>225 lbs</Label>,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const AllVariants: Story = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<View style={{ gap: 12 }}>
|
|
58
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
59
|
+
<BaseBadge size="sm"><Label>225 lbs</Label></BaseBadge>
|
|
60
|
+
<BaseBadge size="md"><Label>225 lbs</Label></BaseBadge>
|
|
61
|
+
<BaseBadge size="lg"><Label>225 lbs</Label></BaseBadge>
|
|
62
|
+
</View>
|
|
63
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
64
|
+
<BaseBadge variant="pr" size="sm"><Label color="#FF7900">PR</Label></BaseBadge>
|
|
65
|
+
<BaseBadge variant="pr" size="md"><Label color="#FF7900">PR</Label></BaseBadge>
|
|
66
|
+
<BaseBadge variant="pr" size="lg"><Label color="#FF7900">PR</Label></BaseBadge>
|
|
67
|
+
</View>
|
|
68
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
69
|
+
<BaseBadge icon={<Dumbbell size={12} color="#9CA3AF" strokeWidth={2} />}>
|
|
70
|
+
<Label>225 lbs</Label>
|
|
71
|
+
</BaseBadge>
|
|
72
|
+
</View>
|
|
73
|
+
</View>
|
|
74
|
+
),
|
|
75
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Text, View } from 'react-native'
|
|
5
|
+
import { BaseBadge } from './BaseBadge'
|
|
6
|
+
|
|
7
|
+
describe('BaseBadge', () => {
|
|
8
|
+
it('renders children', () => {
|
|
9
|
+
render(<BaseBadge testID="bb"><Text>Hello</Text></BaseBadge>)
|
|
10
|
+
expect(screen.getByText('Hello')).toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('defaults to the plain variant border color', () => {
|
|
14
|
+
render(<BaseBadge testID="bb"><Text>x</Text></BaseBadge>)
|
|
15
|
+
expect(screen.getByTestId('bb')).toHaveStyle({ borderTopColor: '#1F1F1F' })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('applies the pr variant border color', () => {
|
|
19
|
+
render(
|
|
20
|
+
<BaseBadge variant="pr" testID="bb">
|
|
21
|
+
<Text>x</Text>
|
|
22
|
+
</BaseBadge>,
|
|
23
|
+
)
|
|
24
|
+
expect(screen.getByTestId('bb')).toHaveStyle({
|
|
25
|
+
borderTopColor: 'rgba(255, 121, 0, 0.12)',
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('icon', () => {
|
|
30
|
+
it('renders the icon when provided and hides it from the a11y tree', () => {
|
|
31
|
+
render(
|
|
32
|
+
<BaseBadge icon={<View testID="my-icon" />}>
|
|
33
|
+
<Text>x</Text>
|
|
34
|
+
</BaseBadge>,
|
|
35
|
+
)
|
|
36
|
+
expect(screen.getByTestId('base-badge-icon')).toBeInTheDocument()
|
|
37
|
+
expect(screen.getByTestId('my-icon')).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not render an icon wrapper when no icon is provided', () => {
|
|
41
|
+
render(<BaseBadge><Text>x</Text></BaseBadge>)
|
|
42
|
+
expect(screen.queryByTestId('base-badge-icon')).not.toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('onPress', () => {
|
|
47
|
+
it('wraps in a Pressable when onPress is provided', () => {
|
|
48
|
+
const onPress = vi.fn()
|
|
49
|
+
render(
|
|
50
|
+
<BaseBadge onPress={onPress} accessibilityLabel="Press me">
|
|
51
|
+
<Text>x</Text>
|
|
52
|
+
</BaseBadge>,
|
|
53
|
+
)
|
|
54
|
+
const pressable = screen.getByTestId('base-badge-pressable')
|
|
55
|
+
fireEvent.click(pressable)
|
|
56
|
+
expect(onPress).toHaveBeenCalledOnce()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not render a Pressable when onPress is omitted', () => {
|
|
60
|
+
render(<BaseBadge><Text>x</Text></BaseBadge>)
|
|
61
|
+
expect(screen.queryByTestId('base-badge-pressable')).not.toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('exposes a button role only when pressable', () => {
|
|
65
|
+
render(
|
|
66
|
+
<BaseBadge onPress={() => {}} accessibilityLabel="Press me">
|
|
67
|
+
<Text>x</Text>
|
|
68
|
+
</BaseBadge>,
|
|
69
|
+
)
|
|
70
|
+
expect(screen.getByRole('button')).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('size', () => {
|
|
75
|
+
it.each(['sm', 'md', 'lg'] as const)('renders %s size', (size) => {
|
|
76
|
+
render(<BaseBadge size={size} testID="bb"><Text>x</Text></BaseBadge>)
|
|
77
|
+
expect(screen.getByTestId('bb')).toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('accessibility', () => {
|
|
82
|
+
it('has no accessibility violations as a static badge', async () => {
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<BaseBadge accessibilityLabel="Static badge">
|
|
85
|
+
<Text>x</Text>
|
|
86
|
+
</BaseBadge>,
|
|
87
|
+
)
|
|
88
|
+
const results = await axe(container)
|
|
89
|
+
expect(results).toHaveNoViolations()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('has no accessibility violations as a pressable badge', async () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<BaseBadge onPress={() => {}} accessibilityLabel="Pressable badge">
|
|
95
|
+
<Text>x</Text>
|
|
96
|
+
</BaseBadge>,
|
|
97
|
+
)
|
|
98
|
+
const results = await axe(container)
|
|
99
|
+
expect(results).toHaveNoViolations()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import { useState, type ReactNode } from 'react'
|
|
3
|
+
import { View, Pressable, Animated, type ViewProps } from 'react-native'
|
|
4
|
+
|
|
5
|
+
export type BaseBadgeVariant = 'plain' | 'pr'
|
|
6
|
+
export type BaseBadgeSize = 'sm' | 'md' | 'lg'
|
|
7
|
+
|
|
8
|
+
export interface BaseBadgeProps extends ViewProps {
|
|
9
|
+
/** Visual variant — plain (neutral) or pr (brand-primary-subtle) */
|
|
10
|
+
variant?: BaseBadgeVariant
|
|
11
|
+
/** Size variant */
|
|
12
|
+
size?: BaseBadgeSize
|
|
13
|
+
/** Optional icon rendered left of children; hidden from a11y tree */
|
|
14
|
+
icon?: ReactNode
|
|
15
|
+
onPress?: () => void
|
|
16
|
+
className?: string
|
|
17
|
+
children?: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const baseBadgeSizeConfig: Record<
|
|
21
|
+
BaseBadgeSize,
|
|
22
|
+
{ fontSize: number; paddingH: number; paddingV: number; iconSize: number }
|
|
23
|
+
> = {
|
|
24
|
+
sm: { fontSize: 9, paddingH: 6, paddingV: 2, iconSize: 10 },
|
|
25
|
+
md: { fontSize: 10, paddingH: 8, paddingV: 2, iconSize: 12 },
|
|
26
|
+
lg: { fontSize: 12, paddingH: 10, paddingV: 4, iconSize: 14 },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const variantColors: Record<
|
|
30
|
+
BaseBadgeVariant,
|
|
31
|
+
{ backgroundColor: string; borderColor: string }
|
|
32
|
+
> = {
|
|
33
|
+
plain: { backgroundColor: '#1C1C1C', borderColor: '#1F1F1F' },
|
|
34
|
+
pr: {
|
|
35
|
+
backgroundColor: 'rgba(255, 121, 0, 0.12)',
|
|
36
|
+
borderColor: 'rgba(255, 121, 0, 0.12)',
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BaseBadge({
|
|
41
|
+
variant = 'plain',
|
|
42
|
+
size = 'md',
|
|
43
|
+
icon,
|
|
44
|
+
onPress,
|
|
45
|
+
className,
|
|
46
|
+
children,
|
|
47
|
+
...props
|
|
48
|
+
}: BaseBadgeProps) {
|
|
49
|
+
const [scaleAnim] = useState(() => new Animated.Value(1))
|
|
50
|
+
const config = baseBadgeSizeConfig[size]
|
|
51
|
+
const colors = variantColors[variant]
|
|
52
|
+
|
|
53
|
+
const handlePressIn = () => {
|
|
54
|
+
Animated.timing(scaleAnim, {
|
|
55
|
+
toValue: 0.97,
|
|
56
|
+
duration: 150,
|
|
57
|
+
useNativeDriver: true,
|
|
58
|
+
}).start()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handlePressOut = () => {
|
|
62
|
+
Animated.timing(scaleAnim, {
|
|
63
|
+
toValue: 1,
|
|
64
|
+
duration: 150,
|
|
65
|
+
useNativeDriver: true,
|
|
66
|
+
}).start()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const badge = (
|
|
70
|
+
<View
|
|
71
|
+
className={className}
|
|
72
|
+
style={{
|
|
73
|
+
flexDirection: 'row',
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
gap: 3,
|
|
76
|
+
// borderRadius 2 is intentional per workout-tokens.ts (squared-off pill)
|
|
77
|
+
borderRadius: 2,
|
|
78
|
+
borderWidth: 1,
|
|
79
|
+
borderColor: colors.borderColor,
|
|
80
|
+
backgroundColor: colors.backgroundColor,
|
|
81
|
+
paddingHorizontal: config.paddingH,
|
|
82
|
+
paddingVertical: config.paddingV,
|
|
83
|
+
}}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
{icon != null && (
|
|
87
|
+
<View accessibilityElementsHidden testID="base-badge-icon">
|
|
88
|
+
{icon}
|
|
89
|
+
</View>
|
|
90
|
+
)}
|
|
91
|
+
{children}
|
|
92
|
+
</View>
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (onPress) {
|
|
96
|
+
return (
|
|
97
|
+
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
|
98
|
+
<Pressable
|
|
99
|
+
onPress={onPress}
|
|
100
|
+
onPressIn={handlePressIn}
|
|
101
|
+
onPressOut={handlePressOut}
|
|
102
|
+
accessibilityRole="button"
|
|
103
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
104
|
+
testID="base-badge-pressable"
|
|
105
|
+
>
|
|
106
|
+
{badge}
|
|
107
|
+
</Pressable>
|
|
108
|
+
</Animated.View>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return badge
|
|
113
|
+
}
|