@titan-design/react-ui 0.1.1 → 0.2.6
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/README.md +47 -1
- package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
- package/dist/chunk-UXVRPOME.mjs.map +1 -0
- package/dist/index.d.mts +101 -12
- package/dist/index.d.ts +101 -12
- package/dist/index.js +1267 -468
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +734 -112
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +86 -1
- package/dist/theme/index.d.ts +86 -1
- package/dist/theme/index.js +166 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/docs/WEB_SETUP.md +217 -0
- package/package.json +14 -2
- package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
- package/src/components/custom/Workout/RestTimer.tsx +172 -0
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
- package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
- package/src/components/custom/Workout/index.ts +2 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/custom/stepper/index.ts +2 -2
- package/src/components/ui/avatar/Avatar.test.tsx +17 -0
- package/src/components/ui/avatar/Avatar.tsx +13 -5
- package/src/components/ui/badge/Badge.test.tsx +17 -0
- package/src/components/ui/badge/Badge.tsx +14 -0
- package/src/components/ui/badge/index.ts +1 -0
- package/src/components/ui/button/Button.tsx +55 -0
- package/src/components/ui/card/Card.test.tsx +29 -0
- package/src/components/ui/card/Card.tsx +32 -11
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
- package/src/components/ui/indicator/Indicator.test.tsx +55 -0
- package/src/components/ui/indicator/Indicator.tsx +73 -0
- package/src/components/ui/indicator/index.ts +2 -0
- package/src/components/ui/pill/Pill.stories.tsx +71 -0
- package/src/components/ui/pill/Pill.test.tsx +69 -0
- package/src/components/ui/pill/Pill.tsx +104 -0
- package/src/components/ui/pill/index.ts +2 -0
- package/src/components/ui/popover/Popover.test.tsx +144 -2
- package/src/components/ui/popover/Popover.tsx +81 -6
- package/src/components/ui/progress/Progress.test.tsx +29 -0
- package/src/components/ui/progress/Progress.tsx +54 -35
- package/src/components/ui/select/Select.test.tsx +20 -0
- package/src/components/ui/select/Select.tsx +9 -2
- package/src/components/ui/toast/index.ts +1 -0
- package/src/components/ui/tooltip/Tooltip.test.tsx +83 -0
- package/src/components/ui/tooltip/Tooltip.tsx +117 -26
- package/src/stories/PresetShowcase.stories.tsx +462 -0
- package/src/stories/ThemePresets.stories.tsx +85 -0
- package/src/theme/global.css +129 -1
- package/src/theme/index.ts +1 -0
- package/src/theme/presets/apply.ts +97 -0
- package/src/theme/presets/audiobook.ts +93 -0
- package/src/theme/presets/default.ts +6 -0
- package/src/theme/presets/index.ts +4 -0
- package/src/theme/presets/presets.test.ts +51 -0
- package/src/theme/presets/types.ts +76 -0
- package/src/utils/avatar-color.test.ts +30 -0
- package/src/utils/avatar-color.ts +20 -0
- package/src/web-jsx/jsx-dev-runtime.ts +28 -0
- package/src/web-jsx/jsx-runtime.ts +41 -0
- package/tailwind.config.js +53 -3
- package/dist/chunk-MGW37MPO.mjs.map +0 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Web Consumer Setup
|
|
2
|
+
|
|
3
|
+
How to use `@titan-design/react-ui` in a Vite-based web application.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- **Vite** 5+ with `@vitejs/plugin-react`
|
|
8
|
+
- **Tailwind CSS** 3.4+
|
|
9
|
+
- **react-native-web** 0.19+
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### 1. Install dependencies
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @titan-design/react-ui react-native-web
|
|
17
|
+
npm install -D tailwindcss autoprefixer postcss nativewind
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> `nativewind` is an **optional peer dependency** and only needed at build time — it
|
|
21
|
+
> provides a Tailwind preset that adds React Native platform variants (`web:`,
|
|
22
|
+
> `native:`) used by titan components. It is NOT needed at runtime on web.
|
|
23
|
+
> Install it as a dev dependency so Tailwind can resolve the preset.
|
|
24
|
+
|
|
25
|
+
### 2. Configure Vite
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// vite.config.ts
|
|
29
|
+
import { defineConfig } from 'vite'
|
|
30
|
+
import react from '@vitejs/plugin-react'
|
|
31
|
+
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
plugins: [react()],
|
|
34
|
+
resolve: {
|
|
35
|
+
alias: {
|
|
36
|
+
'react-native': 'react-native-web',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That's it. No `jsxImportSource`, no `optimizeDeps`, no `resolve.dedupe`.
|
|
43
|
+
|
|
44
|
+
### 3. Configure Tailwind CSS
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
// tailwind.config.js
|
|
48
|
+
const titanConfig = require('@titan-design/react-ui/tailwind.config.js')
|
|
49
|
+
|
|
50
|
+
/** @type {import('tailwindcss').Config} */
|
|
51
|
+
module.exports = {
|
|
52
|
+
content: [
|
|
53
|
+
'./src/**/*.{js,jsx,ts,tsx}',
|
|
54
|
+
'./node_modules/@titan-design/react-ui/dist/**/*.{js,mjs}',
|
|
55
|
+
],
|
|
56
|
+
presets: [titanConfig],
|
|
57
|
+
darkMode: 'class',
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `titanConfig` preset includes `nativewind/preset` which registers the `web:` and
|
|
62
|
+
`native:` Tailwind variants that titan components use (e.g., `web:hover:bg-gray-100`).
|
|
63
|
+
|
|
64
|
+
### 4. Configure PostCSS
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
// postcss.config.js
|
|
68
|
+
module.exports = {
|
|
69
|
+
plugins: {
|
|
70
|
+
tailwindcss: {},
|
|
71
|
+
autoprefixer: {},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 5. Import global CSS
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// main.tsx or App.tsx
|
|
80
|
+
import '@titan-design/react-ui/theme/global.css'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This loads the design token CSS custom properties (colors, typography, spacing, etc.).
|
|
84
|
+
|
|
85
|
+
### 6. Use components
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import { Button, ButtonText, Card, CardContent } from '@titan-design/react-ui'
|
|
89
|
+
|
|
90
|
+
function App() {
|
|
91
|
+
return (
|
|
92
|
+
<Card>
|
|
93
|
+
<CardContent>
|
|
94
|
+
<Button color="primary">
|
|
95
|
+
<ButtonText>Hello</ButtonText>
|
|
96
|
+
</Button>
|
|
97
|
+
</CardContent>
|
|
98
|
+
</Card>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## How It Works
|
|
106
|
+
|
|
107
|
+
Titan's web distribution (the `dist/` you import) uses a custom JSX runtime
|
|
108
|
+
that converts `className` props to
|
|
109
|
+
[`$$css` style objects](https://github.com/nicholasxjy/styleq#compiled-styles)
|
|
110
|
+
at **build time**. react-native-web's `styleq` recognizes these objects and
|
|
111
|
+
applies their values as CSS class names on DOM elements.
|
|
112
|
+
|
|
113
|
+
This means:
|
|
114
|
+
- No NativeWind runtime needed on web
|
|
115
|
+
- No `interopComponents` Map lookup (which breaks under Vite module deduplication)
|
|
116
|
+
- No `jsxImportSource: 'nativewind'` configuration needed
|
|
117
|
+
- className and inline `style` work together correctly (inline style wins on conflicts)
|
|
118
|
+
|
|
119
|
+
### Native React Native
|
|
120
|
+
|
|
121
|
+
On native (iOS/Android), Metro resolves the `react-native` export condition in
|
|
122
|
+
titan's `package.json`, which points to source files. NativeWind's Babel plugin
|
|
123
|
+
(configured in your Metro/Expo setup) handles `className` natively.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Migration from NativeWind-based Setup
|
|
128
|
+
|
|
129
|
+
If you previously configured NativeWind's runtime interop, here's what to remove:
|
|
130
|
+
|
|
131
|
+
### Remove from `vite.config.ts`
|
|
132
|
+
|
|
133
|
+
```diff
|
|
134
|
+
plugins: [
|
|
135
|
+
- react({ jsxImportSource: 'nativewind' }),
|
|
136
|
+
+ react(),
|
|
137
|
+
],
|
|
138
|
+
resolve: {
|
|
139
|
+
alias: { 'react-native': 'react-native-web' },
|
|
140
|
+
- dedupe: ['react-native-web'],
|
|
141
|
+
},
|
|
142
|
+
- optimizeDeps: {
|
|
143
|
+
- esbuildOptions: {
|
|
144
|
+
- loader: { '.js': 'jsx' },
|
|
145
|
+
- },
|
|
146
|
+
- },
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Remove `setup-interop.ts`
|
|
150
|
+
|
|
151
|
+
Delete any file that re-registers `cssInterop` for View/Text/Pressable.
|
|
152
|
+
This was a workaround for Vite's module deduplication breaking
|
|
153
|
+
NativeWind's `interopComponents` Map. It is no longer needed.
|
|
154
|
+
|
|
155
|
+
```diff
|
|
156
|
+
// main.tsx
|
|
157
|
+
- import './setup-interop'
|
|
158
|
+
import '@titan-design/react-ui/theme/global.css'
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Remove runtime dependencies
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
npm uninstall nativewind react-native-css-interop
|
|
165
|
+
npm install -D nativewind # keep as dev dep for Tailwind preset only
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Remove `conditions` from Vite resolve
|
|
169
|
+
|
|
170
|
+
If you were using `conditions: ['react-native']` to resolve titan's source
|
|
171
|
+
files, remove it. The dist now handles className conversion internally.
|
|
172
|
+
|
|
173
|
+
```diff
|
|
174
|
+
resolve: {
|
|
175
|
+
- conditions: ['react-native', 'import'],
|
|
176
|
+
alias: { 'react-native': 'react-native-web' },
|
|
177
|
+
},
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Troubleshooting
|
|
183
|
+
|
|
184
|
+
### Tailwind classes not appearing
|
|
185
|
+
|
|
186
|
+
1. Verify your `tailwind.config.js` content array includes titan's dist:
|
|
187
|
+
```js
|
|
188
|
+
content: [
|
|
189
|
+
'./node_modules/@titan-design/react-ui/dist/**/*.{js,mjs}',
|
|
190
|
+
]
|
|
191
|
+
```
|
|
192
|
+
2. Verify PostCSS is configured with the `tailwindcss` plugin
|
|
193
|
+
3. Verify you imported `@titan-design/react-ui/theme/global.css`
|
|
194
|
+
|
|
195
|
+
### Layout properties not applying (flexDirection, alignItems, etc.)
|
|
196
|
+
|
|
197
|
+
This was the original NativeWind interop issue. If you see this after upgrading,
|
|
198
|
+
ensure you've updated `@titan-design/react-ui` to a version with the custom
|
|
199
|
+
JSX runtime (0.3.0+). Older versions require the NativeWind runtime setup.
|
|
200
|
+
|
|
201
|
+
### Your own `<View className="...">` components don't get styles
|
|
202
|
+
|
|
203
|
+
Titan's custom JSX runtime only applies to titan's pre-built dist. If your app
|
|
204
|
+
code directly uses React Native primitives with className, you have two options:
|
|
205
|
+
|
|
206
|
+
1. **Use standard CSS/Tailwind on web**: Write `<div className="flex-row">` for
|
|
207
|
+
your own web-only components
|
|
208
|
+
2. **Set up NativeWind for your app's JSX**: Add `jsxImportSource: 'nativewind'`
|
|
209
|
+
to your Vite React plugin config. This only affects your app's source files
|
|
210
|
+
(titan's dist is already handled)
|
|
211
|
+
|
|
212
|
+
### Using titan with Webpack instead of Vite
|
|
213
|
+
|
|
214
|
+
The same principle applies. You need:
|
|
215
|
+
1. `react-native` → `react-native-web` alias (via `resolve.alias` in webpack config)
|
|
216
|
+
2. Tailwind CSS configured with titan's content paths
|
|
217
|
+
3. No NativeWind runtime setup needed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@titan-design/react-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Cross-platform design system built on Gluestack UI",
|
|
5
5
|
"author": "Henry Jewkes",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,23 +41,33 @@
|
|
|
41
41
|
"build": "tsup",
|
|
42
42
|
"dev": "tsup --watch",
|
|
43
43
|
"lint": "eslint src/",
|
|
44
|
+
"lint:borders": "bash scripts/check-border-classes.sh",
|
|
44
45
|
"type-check": "tsc --noEmit",
|
|
45
46
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
|
46
47
|
"test": "vitest",
|
|
47
48
|
"test:coverage": "vitest --coverage",
|
|
48
49
|
"storybook": "storybook dev -p 6006",
|
|
49
50
|
"build-storybook": "storybook build",
|
|
51
|
+
"test:visual": "playwright test",
|
|
52
|
+
"test:visual:update": "playwright test --update-snapshots",
|
|
53
|
+
"specimen": "vite --config specimen/vite.config.ts",
|
|
54
|
+
"specimen:compare": "vite --config specimen/vite.config.ts --open /comparison.html",
|
|
55
|
+
"test:visual:compare": "playwright test --config playwright.comparison.config.ts",
|
|
50
56
|
"prepublishOnly": "tsup && vitest --run"
|
|
51
57
|
},
|
|
52
58
|
"peerDependencies": {
|
|
53
59
|
"lucide-react": ">=0.400.0",
|
|
54
60
|
"lucide-react-native": ">=0.400.0",
|
|
61
|
+
"nativewind": "^4.2.1",
|
|
55
62
|
"react": "^18.0.0 || ^19.0.0",
|
|
56
63
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
57
64
|
"react-native": ">=0.72.0",
|
|
58
65
|
"react-native-web": ">=0.19.0"
|
|
59
66
|
},
|
|
60
67
|
"peerDependenciesMeta": {
|
|
68
|
+
"nativewind": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
61
71
|
"react-native": {
|
|
62
72
|
"optional": true
|
|
63
73
|
},
|
|
@@ -72,11 +82,12 @@
|
|
|
72
82
|
"@gluestack-style/react": "^1.0.0",
|
|
73
83
|
"@gluestack-ui/themed": "^1.1.0",
|
|
74
84
|
"clsx": "^2.1.0",
|
|
75
|
-
"nativewind": "^4.1.0",
|
|
76
85
|
"tailwind-merge": "^2.6.0"
|
|
77
86
|
},
|
|
78
87
|
"devDependencies": {
|
|
88
|
+
"nativewind": "^4.2.1",
|
|
79
89
|
"@eslint/js": "^10.0.1",
|
|
90
|
+
"@playwright/test": "^1.58.2",
|
|
80
91
|
"@storybook/addon-a11y": "^10.2.0",
|
|
81
92
|
"@storybook/addon-docs": "^10.2.0",
|
|
82
93
|
"@storybook/addon-react-native-web": "^0.0.29",
|
|
@@ -112,6 +123,7 @@
|
|
|
112
123
|
"files": [
|
|
113
124
|
"dist",
|
|
114
125
|
"src",
|
|
126
|
+
"docs",
|
|
115
127
|
"tailwind.config.js"
|
|
116
128
|
],
|
|
117
129
|
"sideEffects": false,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { RestTimer } from './RestTimer'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof RestTimer> = {
|
|
6
|
+
title: 'Custom/Workout/RestTimer',
|
|
7
|
+
component: RestTimer,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
totalSeconds: {
|
|
11
|
+
control: 'number',
|
|
12
|
+
description: 'Total rest duration in seconds',
|
|
13
|
+
},
|
|
14
|
+
elapsedMs: {
|
|
15
|
+
control: 'number',
|
|
16
|
+
description: 'Elapsed time in milliseconds',
|
|
17
|
+
},
|
|
18
|
+
visible: {
|
|
19
|
+
control: 'boolean',
|
|
20
|
+
description: 'Whether the timer is visible',
|
|
21
|
+
},
|
|
22
|
+
nextSetInfo: {
|
|
23
|
+
control: 'text',
|
|
24
|
+
description: 'Context about the next set',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default meta
|
|
30
|
+
type Story = StoryObj<typeof RestTimer>
|
|
31
|
+
|
|
32
|
+
export const Default: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
totalSeconds: 150,
|
|
35
|
+
elapsedMs: 30000,
|
|
36
|
+
onSkip: () => {},
|
|
37
|
+
onAddTime: () => {},
|
|
38
|
+
visible: true,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const NearlyComplete: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
totalSeconds: 150,
|
|
45
|
+
elapsedMs: 145000,
|
|
46
|
+
onSkip: () => {},
|
|
47
|
+
onAddTime: () => {},
|
|
48
|
+
visible: true,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const WithNextSetInfo: Story = {
|
|
53
|
+
args: {
|
|
54
|
+
totalSeconds: 150,
|
|
55
|
+
elapsedMs: 30000,
|
|
56
|
+
onSkip: () => {},
|
|
57
|
+
onAddTime: () => {},
|
|
58
|
+
nextSetInfo: 'Bench Press — Set 3 of 4',
|
|
59
|
+
visible: true,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const JustStarted: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
totalSeconds: 150,
|
|
66
|
+
elapsedMs: 0,
|
|
67
|
+
onSkip: () => {},
|
|
68
|
+
onAddTime: () => {},
|
|
69
|
+
visible: true,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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 { RestTimer } from './RestTimer'
|
|
5
|
+
|
|
6
|
+
const defaultProps = {
|
|
7
|
+
totalSeconds: 150,
|
|
8
|
+
elapsedMs: 30000,
|
|
9
|
+
onSkip: vi.fn(),
|
|
10
|
+
onAddTime: vi.fn(),
|
|
11
|
+
visible: true,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('RestTimer', () => {
|
|
15
|
+
describe('time display formatting', () => {
|
|
16
|
+
it('displays 2:30 for 150s total with 0 elapsed', () => {
|
|
17
|
+
render(<RestTimer {...defaultProps} elapsedMs={0} />)
|
|
18
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('2:30')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('displays 2:00 for 150s total with 30s elapsed', () => {
|
|
22
|
+
render(<RestTimer {...defaultProps} elapsedMs={30000} />)
|
|
23
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('2:00')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('displays 0:05 for 150s total with 145s elapsed', () => {
|
|
27
|
+
render(<RestTimer {...defaultProps} elapsedMs={145000} />)
|
|
28
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('0:05')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('pads seconds with leading zero', () => {
|
|
32
|
+
render(<RestTimer {...defaultProps} totalSeconds={65} elapsedMs={0} />)
|
|
33
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('1:05')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('displays 0:00 when elapsed equals total', () => {
|
|
37
|
+
render(<RestTimer {...defaultProps} elapsedMs={150000} />)
|
|
38
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('0:00')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('progress bar', () => {
|
|
43
|
+
it('sets width to 0% when elapsed is 0', () => {
|
|
44
|
+
render(<RestTimer {...defaultProps} elapsedMs={0} />)
|
|
45
|
+
const fill = screen.getByTestId('rest-timer-progress-fill')
|
|
46
|
+
expect(fill).toHaveStyle({ width: '0%' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('sets width to 50% when half elapsed', () => {
|
|
50
|
+
render(<RestTimer {...defaultProps} totalSeconds={100} elapsedMs={50000} />)
|
|
51
|
+
const fill = screen.getByTestId('rest-timer-progress-fill')
|
|
52
|
+
expect(fill).toHaveStyle({ width: '50%' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('clamps width to 100% when elapsed exceeds total', () => {
|
|
56
|
+
render(<RestTimer {...defaultProps} elapsedMs={200000} />)
|
|
57
|
+
const fill = screen.getByTestId('rest-timer-progress-fill')
|
|
58
|
+
expect(fill).toHaveStyle({ width: '100%' })
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('button interactions', () => {
|
|
63
|
+
it('fires onSkip when skip button is pressed', () => {
|
|
64
|
+
const onSkip = vi.fn()
|
|
65
|
+
render(<RestTimer {...defaultProps} onSkip={onSkip} />)
|
|
66
|
+
fireEvent.click(screen.getByTestId('rest-timer-skip'))
|
|
67
|
+
expect(onSkip).toHaveBeenCalledOnce()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('fires onAddTime when +30s button is pressed', () => {
|
|
71
|
+
const onAddTime = vi.fn()
|
|
72
|
+
render(<RestTimer {...defaultProps} onAddTime={onAddTime} />)
|
|
73
|
+
fireEvent.click(screen.getByTestId('rest-timer-add-time'))
|
|
74
|
+
expect(onAddTime).toHaveBeenCalledOnce()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('next set info', () => {
|
|
79
|
+
it('displays nextSetInfo when provided', () => {
|
|
80
|
+
render(
|
|
81
|
+
<RestTimer
|
|
82
|
+
{...defaultProps}
|
|
83
|
+
nextSetInfo="Bench Press — Set 3 of 4"
|
|
84
|
+
/>,
|
|
85
|
+
)
|
|
86
|
+
expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
|
|
87
|
+
'Bench Press — Set 3 of 4',
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not render next set element when nextSetInfo is not provided', () => {
|
|
92
|
+
render(<RestTimer {...defaultProps} />)
|
|
93
|
+
expect(screen.queryByTestId('rest-timer-next-set')).not.toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('visibility', () => {
|
|
98
|
+
it('renders nothing when visible is false', () => {
|
|
99
|
+
render(<RestTimer {...defaultProps} visible={false} />)
|
|
100
|
+
expect(screen.queryByTestId('rest-timer')).not.toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('renders when visible is true', () => {
|
|
104
|
+
render(<RestTimer {...defaultProps} visible />)
|
|
105
|
+
expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('edge cases', () => {
|
|
110
|
+
it('clamps remaining to 0 when elapsed exceeds total', () => {
|
|
111
|
+
render(<RestTimer {...defaultProps} elapsedMs={200000} />)
|
|
112
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('0:00')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('displays full time when elapsed is 0', () => {
|
|
116
|
+
render(<RestTimer {...defaultProps} totalSeconds={90} elapsedMs={0} />)
|
|
117
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('1:30')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('accessibility', () => {
|
|
122
|
+
it('has no accessibility violations', async () => {
|
|
123
|
+
const { container } = render(<RestTimer {...defaultProps} />)
|
|
124
|
+
const results = await axe(container)
|
|
125
|
+
expect(results).toHaveNoViolations()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('has timer role with remaining seconds in label', () => {
|
|
129
|
+
render(<RestTimer {...defaultProps} totalSeconds={90} elapsedMs={30000} />)
|
|
130
|
+
expect(
|
|
131
|
+
screen.getByLabelText('Rest timer, 60 seconds remaining'),
|
|
132
|
+
).toBeInTheDocument()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('skip button has accessible label', () => {
|
|
136
|
+
render(<RestTimer {...defaultProps} />)
|
|
137
|
+
expect(screen.getByLabelText('Skip rest')).toBeInTheDocument()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('add time button has accessible label', () => {
|
|
141
|
+
render(<RestTimer {...defaultProps} />)
|
|
142
|
+
expect(screen.getByLabelText('Add 30 seconds')).toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { View, Text, Pressable } from 'react-native'
|
|
4
|
+
|
|
5
|
+
export interface RestTimerProps {
|
|
6
|
+
totalSeconds: number
|
|
7
|
+
elapsedMs: number
|
|
8
|
+
onSkip: () => void
|
|
9
|
+
onAddTime: () => void
|
|
10
|
+
nextSetInfo?: string
|
|
11
|
+
visible: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function RestTimer({
|
|
15
|
+
totalSeconds,
|
|
16
|
+
elapsedMs,
|
|
17
|
+
onSkip,
|
|
18
|
+
onAddTime,
|
|
19
|
+
nextSetInfo,
|
|
20
|
+
visible,
|
|
21
|
+
}: RestTimerProps) {
|
|
22
|
+
if (!visible) return null
|
|
23
|
+
|
|
24
|
+
const remainingMs = Math.max(0, totalSeconds * 1000 - elapsedMs)
|
|
25
|
+
const remainingSec = Math.ceil(remainingMs / 1000)
|
|
26
|
+
const minutes = Math.floor(remainingSec / 60)
|
|
27
|
+
const seconds = remainingSec % 60
|
|
28
|
+
const timeDisplay = `${minutes}:${String(seconds).padStart(2, '0')}`
|
|
29
|
+
const progressPct = Math.min(100, (elapsedMs / (totalSeconds * 1000)) * 100)
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<View
|
|
33
|
+
style={{
|
|
34
|
+
width: '100%',
|
|
35
|
+
backgroundColor: '#1C1C1C',
|
|
36
|
+
borderTopWidth: 1,
|
|
37
|
+
borderTopColor: '#1F1F1F',
|
|
38
|
+
paddingVertical: 12,
|
|
39
|
+
paddingHorizontal: 16,
|
|
40
|
+
}}
|
|
41
|
+
accessibilityRole="timer"
|
|
42
|
+
accessibilityLabel={`Rest timer, ${remainingSec} seconds remaining`}
|
|
43
|
+
testID="rest-timer"
|
|
44
|
+
>
|
|
45
|
+
{/* Top row */}
|
|
46
|
+
<View
|
|
47
|
+
style={{
|
|
48
|
+
flexDirection: 'row',
|
|
49
|
+
justifyContent: 'space-between',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
marginBottom: 8,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{/* Left side */}
|
|
55
|
+
<View style={{ flexDirection: 'column' }}>
|
|
56
|
+
<Text
|
|
57
|
+
style={{
|
|
58
|
+
fontSize: 11,
|
|
59
|
+
fontFamily: 'Inter, sans-serif',
|
|
60
|
+
fontWeight: '600',
|
|
61
|
+
textTransform: 'uppercase',
|
|
62
|
+
letterSpacing: 1,
|
|
63
|
+
color: '#9CA3AF',
|
|
64
|
+
}}
|
|
65
|
+
testID="rest-timer-label"
|
|
66
|
+
>
|
|
67
|
+
REST
|
|
68
|
+
</Text>
|
|
69
|
+
{nextSetInfo != null && (
|
|
70
|
+
<Text
|
|
71
|
+
style={{
|
|
72
|
+
fontSize: 11,
|
|
73
|
+
fontFamily: 'Inter, sans-serif',
|
|
74
|
+
color: '#6B7280',
|
|
75
|
+
marginTop: 2,
|
|
76
|
+
}}
|
|
77
|
+
testID="rest-timer-next-set"
|
|
78
|
+
>
|
|
79
|
+
{nextSetInfo}
|
|
80
|
+
</Text>
|
|
81
|
+
)}
|
|
82
|
+
</View>
|
|
83
|
+
|
|
84
|
+
{/* Right side - time display */}
|
|
85
|
+
<Text
|
|
86
|
+
style={{
|
|
87
|
+
fontSize: 28,
|
|
88
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
89
|
+
fontWeight: '700',
|
|
90
|
+
color: '#F3F4F6',
|
|
91
|
+
fontVariant: ['tabular-nums'],
|
|
92
|
+
letterSpacing: -0.5,
|
|
93
|
+
}}
|
|
94
|
+
testID="rest-timer-time"
|
|
95
|
+
>
|
|
96
|
+
{timeDisplay}
|
|
97
|
+
</Text>
|
|
98
|
+
</View>
|
|
99
|
+
|
|
100
|
+
{/* Progress bar */}
|
|
101
|
+
<View
|
|
102
|
+
style={{
|
|
103
|
+
height: 3,
|
|
104
|
+
backgroundColor: '#1F1F1F',
|
|
105
|
+
borderRadius: 2,
|
|
106
|
+
marginBottom: 12,
|
|
107
|
+
}}
|
|
108
|
+
testID="rest-timer-progress-track"
|
|
109
|
+
>
|
|
110
|
+
<View
|
|
111
|
+
style={{
|
|
112
|
+
height: '100%',
|
|
113
|
+
backgroundColor: '#FF7900',
|
|
114
|
+
borderRadius: 2,
|
|
115
|
+
width: `${progressPct}%`,
|
|
116
|
+
}}
|
|
117
|
+
testID="rest-timer-progress-fill"
|
|
118
|
+
/>
|
|
119
|
+
</View>
|
|
120
|
+
|
|
121
|
+
{/* Actions row */}
|
|
122
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
123
|
+
<Pressable
|
|
124
|
+
onPress={onAddTime}
|
|
125
|
+
style={{
|
|
126
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
127
|
+
paddingVertical: 8,
|
|
128
|
+
paddingHorizontal: 20,
|
|
129
|
+
borderRadius: 8,
|
|
130
|
+
}}
|
|
131
|
+
accessibilityRole="button"
|
|
132
|
+
accessibilityLabel="Add 30 seconds"
|
|
133
|
+
testID="rest-timer-add-time"
|
|
134
|
+
>
|
|
135
|
+
<Text
|
|
136
|
+
style={{
|
|
137
|
+
fontSize: 11,
|
|
138
|
+
fontFamily: 'Inter, sans-serif',
|
|
139
|
+
fontWeight: '600',
|
|
140
|
+
color: '#9CA3AF',
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
+30s
|
|
144
|
+
</Text>
|
|
145
|
+
</Pressable>
|
|
146
|
+
<Pressable
|
|
147
|
+
onPress={onSkip}
|
|
148
|
+
style={{
|
|
149
|
+
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
150
|
+
paddingVertical: 8,
|
|
151
|
+
paddingHorizontal: 20,
|
|
152
|
+
borderRadius: 8,
|
|
153
|
+
}}
|
|
154
|
+
accessibilityRole="button"
|
|
155
|
+
accessibilityLabel="Skip rest"
|
|
156
|
+
testID="rest-timer-skip"
|
|
157
|
+
>
|
|
158
|
+
<Text
|
|
159
|
+
style={{
|
|
160
|
+
fontSize: 11,
|
|
161
|
+
fontFamily: 'Inter, sans-serif',
|
|
162
|
+
fontWeight: '600',
|
|
163
|
+
color: '#FF7900',
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
Skip
|
|
167
|
+
</Text>
|
|
168
|
+
</Pressable>
|
|
169
|
+
</View>
|
|
170
|
+
</View>
|
|
171
|
+
)
|
|
172
|
+
}
|