ui-soxo-bootstrap-core 2.6.28 → 2.6.29
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/core/modules/steps/steps.js +260 -6
- package/core/modules/steps/steps.scss +185 -0
- package/package.json +1 -1
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* - Dynamically renders step-specific components based on configuration.
|
|
6
6
|
* - Tracks step and process durations with local persistence support.
|
|
7
7
|
* - Supports step navigation (next, previous, skip, breadcrumb, keyboard).
|
|
8
|
+
* - Touchscreen support: horizontal swipe gestures navigate between steps
|
|
9
|
+
* and transient left/right arrow buttons fade in on touch for discovery.
|
|
8
10
|
* - Handles process submission and optional chaining to the next process.
|
|
9
11
|
* - Renders a single active step view with compact breadcrumb controls.
|
|
10
12
|
*/
|
|
@@ -19,6 +21,21 @@ import { Button, Card, Location } from './../../lib';
|
|
|
19
21
|
import { createOpenAIRealtimeSession, hasOpenAIRealtimeCredentials } from './openai-realtime';
|
|
20
22
|
import './steps.scss';
|
|
21
23
|
|
|
24
|
+
const TOUCH_NAV_HIDE_DELAY = 2800;
|
|
25
|
+
const SWIPE_DISTANCE_THRESHOLD = 60;
|
|
26
|
+
const SWIPE_VERTICAL_TOLERANCE = 80;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* First-step CTA labels keyed by normalized process name.
|
|
30
|
+
* - Keys are the lowercased/trimmed process name returned by the backend.
|
|
31
|
+
* - Missing keys fall back to the generic 'Next' label at the call site.
|
|
32
|
+
* - Frozen so accidental mutation during render doesn't leak across renders.
|
|
33
|
+
*/
|
|
34
|
+
const FIRST_STEP_LABELS = Object.freeze({
|
|
35
|
+
verification: 'Verify Profile',
|
|
36
|
+
consultation: 'Start Consultation',
|
|
37
|
+
});
|
|
38
|
+
|
|
22
39
|
const STEP_WELCOME_LINES = [
|
|
23
40
|
'Welcome to your AI Automated Consultation process.',
|
|
24
41
|
'You are in the right place for a smooth and guided health journey.',
|
|
@@ -273,6 +290,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
273
290
|
|
|
274
291
|
const [loading, setLoading] = useState(false);
|
|
275
292
|
const [steps, setSteps] = useState([]);
|
|
293
|
+
const [processName, setProcessName] = useState(null);
|
|
276
294
|
const [activeStep, setActiveStep] = useState(0);
|
|
277
295
|
const [isStepCompleted, setIsStepCompleted] = useState(false);
|
|
278
296
|
|
|
@@ -300,6 +318,8 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
300
318
|
const [showNextProcessAction, setShowNextProcessAction] = useState(false);
|
|
301
319
|
const [isStepFullscreen, setIsStepFullscreen] = useState(false);
|
|
302
320
|
const [realtimeStatus, setRealtimeStatus] = useState('idle');
|
|
321
|
+
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
|
322
|
+
const [touchNavVisible, setTouchNavVisible] = useState(false);
|
|
303
323
|
|
|
304
324
|
const narrationUtteranceRef = useRef(null);
|
|
305
325
|
const narrationAudioRef = useRef(null);
|
|
@@ -307,6 +327,8 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
307
327
|
const narrationFallbackNoticeRef = useRef(false);
|
|
308
328
|
const realtimeSessionRef = useRef(null);
|
|
309
329
|
const fullscreenViewportRef = useRef(null);
|
|
330
|
+
const touchStartRef = useRef(null);
|
|
331
|
+
const touchNavHideTimeoutRef = useRef(null);
|
|
310
332
|
|
|
311
333
|
const urlParams = Location.search();
|
|
312
334
|
const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
|
|
@@ -336,6 +358,40 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
336
358
|
setShowNextProcessAction(false);
|
|
337
359
|
}, [currentProcessId]);
|
|
338
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Sync the loaded process name into the address bar.
|
|
363
|
+
* - Mirrors `processName` into a `process` query parameter so deep-links and
|
|
364
|
+
* refreshes carry the human-readable process label.
|
|
365
|
+
* - Uses `window.history.replaceState` to avoid a navigation event, which
|
|
366
|
+
* keeps React Router state and component instances stable.
|
|
367
|
+
* - Removes the param when `processName` is null/empty so stale values do
|
|
368
|
+
* not linger after a process clears.
|
|
369
|
+
*/
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (typeof window === 'undefined') {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const params = new URLSearchParams(window.location.search);
|
|
376
|
+
const trimmedName = typeof processName === 'string' ? processName.trim() : '';
|
|
377
|
+
|
|
378
|
+
if (trimmedName) {
|
|
379
|
+
if (params.get('process') === trimmedName) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
params.set('process', trimmedName);
|
|
383
|
+
} else {
|
|
384
|
+
if (!params.has('process')) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
params.delete('process');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const search = params.toString();
|
|
391
|
+
const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash || ''}`;
|
|
392
|
+
window.history.replaceState(window.history.state, '', newUrl);
|
|
393
|
+
}, [processName]);
|
|
394
|
+
|
|
339
395
|
//// Reset step start time whenever the active step changes
|
|
340
396
|
|
|
341
397
|
useEffect(() => {
|
|
@@ -506,6 +562,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
506
562
|
const result = await Dashboard.loadProcess(processId);
|
|
507
563
|
|
|
508
564
|
setSteps(result?.data?.steps || []);
|
|
565
|
+
setProcessName(result?.data?.process_name ?? null);
|
|
509
566
|
if (result?.data?.next_process_id) setNextProcessId(result.data);
|
|
510
567
|
} catch (e) {
|
|
511
568
|
console.error('Error loading process steps:', e);
|
|
@@ -1027,7 +1084,19 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
1027
1084
|
* - Passes configuration, parameters, and handlers to the component.
|
|
1028
1085
|
* - Handles missing steps or components gracefully.
|
|
1029
1086
|
*/
|
|
1030
|
-
|
|
1087
|
+
/**
|
|
1088
|
+
* Render the active step's dynamic component.
|
|
1089
|
+
*
|
|
1090
|
+
* Intentionally a plain function (not a component) called inline as
|
|
1091
|
+
* `{renderDynamicStep()}`. Defining it as a component inside the parent's
|
|
1092
|
+
* render body creates a fresh component *type* on every re-render — React
|
|
1093
|
+
* then unmounts and remounts the step component on every parent state
|
|
1094
|
+
* change (touchNavVisible, stepSlideDirection, activeStep timer, etc.),
|
|
1095
|
+
* which caused visible re-render/jitter during swipe navigation. Evaluating
|
|
1096
|
+
* it as a function just yields JSX for the real step Component, whose type
|
|
1097
|
+
* is stable, so React reconciles in place.
|
|
1098
|
+
*/
|
|
1099
|
+
const renderDynamicStep = () => {
|
|
1031
1100
|
const step = steps[activeStep];
|
|
1032
1101
|
if (!step) return <Empty description="No step selected" />;
|
|
1033
1102
|
|
|
@@ -1065,6 +1134,128 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
1065
1134
|
};
|
|
1066
1135
|
}, [activeStep, steps, externalWin]);
|
|
1067
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* Touch-device detection.
|
|
1139
|
+
* - Runs once on mount.
|
|
1140
|
+
* - Uses `matchMedia('(pointer: coarse)')` as the primary signal because it
|
|
1141
|
+
* targets the actual input hardware (covers touch laptops correctly) and
|
|
1142
|
+
* falls back to `ontouchstart` / `navigator.maxTouchPoints` for older
|
|
1143
|
+
* browsers.
|
|
1144
|
+
* - When neither signal matches, the effect bails out and `isTouchDevice`
|
|
1145
|
+
* stays false so desktop renders without any touch-only UI.
|
|
1146
|
+
*/
|
|
1147
|
+
useEffect(() => {
|
|
1148
|
+
if (typeof window === 'undefined') {
|
|
1149
|
+
return undefined;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const hasCoarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
|
|
1153
|
+
const hasTouch = 'ontouchstart' in window || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
|
|
1154
|
+
|
|
1155
|
+
if (!hasCoarsePointer && !hasTouch) {
|
|
1156
|
+
return undefined;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
setIsTouchDevice(true);
|
|
1160
|
+
}, []);
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Show the floating prev/next arrow buttons and reset their auto-hide timer.
|
|
1164
|
+
* - Any pending hide timeout is cleared so a rapid sequence of touches keeps
|
|
1165
|
+
* the arrows on-screen continuously instead of flickering.
|
|
1166
|
+
* - A fresh timeout is scheduled for TOUCH_NAV_HIDE_DELAY so the arrows fade
|
|
1167
|
+
* away once the user stops interacting, keeping the step content clear.
|
|
1168
|
+
*/
|
|
1169
|
+
const revealTouchNav = () => {
|
|
1170
|
+
if (typeof window === 'undefined') {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
setTouchNavVisible(true);
|
|
1175
|
+
|
|
1176
|
+
if (touchNavHideTimeoutRef.current) {
|
|
1177
|
+
window.clearTimeout(touchNavHideTimeoutRef.current);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
touchNavHideTimeoutRef.current = window.setTimeout(() => {
|
|
1181
|
+
setTouchNavVisible(false);
|
|
1182
|
+
touchNavHideTimeoutRef.current = null;
|
|
1183
|
+
}, TOUCH_NAV_HIDE_DELAY);
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* onTouchStart for the stage body.
|
|
1188
|
+
* - Records the initial touch position so handleStageTouchEnd can measure
|
|
1189
|
+
* the swipe delta.
|
|
1190
|
+
* - Also reveals the side arrows immediately, giving the user a visible
|
|
1191
|
+
* navigation affordance as soon as they touch the screen.
|
|
1192
|
+
*/
|
|
1193
|
+
const handleStageTouchStart = (event) => {
|
|
1194
|
+
if (!isTouchDevice || !event.touches || !event.touches.length) {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const touch = event.touches[0];
|
|
1199
|
+
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
|
|
1200
|
+
revealTouchNav();
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* onTouchEnd for the stage body.
|
|
1205
|
+
* - Computes the horizontal/vertical delta against the stored touch origin.
|
|
1206
|
+
* - Ignores gestures that are vertical-dominant or below the distance
|
|
1207
|
+
* threshold, so normal scrolling and short taps are not hijacked.
|
|
1208
|
+
* - A left swipe advances to the next step (subject to the same
|
|
1209
|
+
* `isStepCompleted` / final-step rules as the visible Next button); a
|
|
1210
|
+
* right swipe goes back. Each successful swipe re-reveals the arrows so
|
|
1211
|
+
* the user can continue tapping if they prefer.
|
|
1212
|
+
*/
|
|
1213
|
+
const handleStageTouchEnd = (event) => {
|
|
1214
|
+
const start = touchStartRef.current;
|
|
1215
|
+
touchStartRef.current = null;
|
|
1216
|
+
|
|
1217
|
+
if (!start || !event.changedTouches || !event.changedTouches.length) {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const touch = event.changedTouches[0];
|
|
1222
|
+
const deltaX = touch.clientX - start.x;
|
|
1223
|
+
const deltaY = touch.clientY - start.y;
|
|
1224
|
+
|
|
1225
|
+
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (Math.abs(deltaY) > SWIPE_VERTICAL_TOLERANCE) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (deltaX < 0) {
|
|
1236
|
+
const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
|
|
1237
|
+
if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
|
|
1238
|
+
handleNext();
|
|
1239
|
+
revealTouchNav();
|
|
1240
|
+
}
|
|
1241
|
+
} else if (activeStep > 0) {
|
|
1242
|
+
handlePrevious();
|
|
1243
|
+
revealTouchNav();
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Cleanup any pending auto-hide timeout on unmount so the callback cannot
|
|
1249
|
+
* fire against a stale component and emit a React warning.
|
|
1250
|
+
*/
|
|
1251
|
+
useEffect(() => {
|
|
1252
|
+
return () => {
|
|
1253
|
+
if (typeof window !== 'undefined' && touchNavHideTimeoutRef.current) {
|
|
1254
|
+
window.clearTimeout(touchNavHideTimeoutRef.current);
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
}, []);
|
|
1258
|
+
|
|
1068
1259
|
useEffect(() => {
|
|
1069
1260
|
if (typeof document === 'undefined') {
|
|
1070
1261
|
return undefined;
|
|
@@ -1180,12 +1371,12 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
1180
1371
|
</div>
|
|
1181
1372
|
|
|
1182
1373
|
<div className="steps-nav-actions">
|
|
1183
|
-
<Button icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
|
|
1374
|
+
<Button type="dashed" icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
|
|
1184
1375
|
{isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
|
|
1185
1376
|
</Button>
|
|
1186
1377
|
|
|
1187
1378
|
{activeStep > 0 && (
|
|
1188
|
-
<Button icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
|
|
1379
|
+
<Button type="default" icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
|
|
1189
1380
|
Back
|
|
1190
1381
|
</Button>
|
|
1191
1382
|
)}
|
|
@@ -1219,14 +1410,77 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
1219
1410
|
</>
|
|
1220
1411
|
) : (
|
|
1221
1412
|
<Button type="primary" disabled={!isStepCompleted} onClick={handleNext}>
|
|
1222
|
-
{
|
|
1413
|
+
{/*
|
|
1414
|
+
First-step label is resolved via FIRST_STEP_LABELS using
|
|
1415
|
+
the process name (lowercased + trimmed) as the key. Known
|
|
1416
|
+
processes get a tailored CTA (e.g. "Verify Profile",
|
|
1417
|
+
"Start Consultation"); unknown processes fall back to the
|
|
1418
|
+
generic "Next" label. All non-first steps always render
|
|
1419
|
+
"Next".
|
|
1420
|
+
*/}
|
|
1421
|
+
{activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}{' '}
|
|
1422
|
+
<ArrowRightOutlined />
|
|
1223
1423
|
</Button>
|
|
1224
1424
|
)}
|
|
1225
1425
|
</div>
|
|
1226
1426
|
</div>
|
|
1227
1427
|
|
|
1228
1428
|
<div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
|
|
1229
|
-
|
|
1429
|
+
{/*
|
|
1430
|
+
Stage body:
|
|
1431
|
+
- `is-swipe-enabled` applies `touch-action: pan-y` so horizontal
|
|
1432
|
+
gestures reach our handlers while vertical scrolling remains
|
|
1433
|
+
native.
|
|
1434
|
+
- Touch handlers are only attached on touch devices to keep
|
|
1435
|
+
desktop event trees untouched.
|
|
1436
|
+
*/}
|
|
1437
|
+
<div
|
|
1438
|
+
className={`steps-stage-body${isTouchDevice ? ' is-swipe-enabled' : ''}`}
|
|
1439
|
+
onTouchStart={isTouchDevice ? handleStageTouchStart : undefined}
|
|
1440
|
+
onTouchEnd={isTouchDevice ? handleStageTouchEnd : undefined}
|
|
1441
|
+
>
|
|
1442
|
+
{/*
|
|
1443
|
+
Floating prev/next arrow buttons.
|
|
1444
|
+
- Rendered only on touch devices; `is-visible` class drives
|
|
1445
|
+
the fade-in/out via CSS transitions.
|
|
1446
|
+
- Disabled states mirror the visible Next/Back buttons in the
|
|
1447
|
+
top bar: previous disabled on the first step; next disabled
|
|
1448
|
+
on the last/final step or when the current step still
|
|
1449
|
+
requires user completion (isStepCompleted === false).
|
|
1450
|
+
- Clicking either button reveals the arrows again so the
|
|
1451
|
+
auto-hide timer restarts after every interaction.
|
|
1452
|
+
*/}
|
|
1453
|
+
{isTouchDevice ? (
|
|
1454
|
+
<>
|
|
1455
|
+
<button
|
|
1456
|
+
type="button"
|
|
1457
|
+
className={`steps-touch-nav steps-touch-nav-left${touchNavVisible ? ' is-visible' : ''}`}
|
|
1458
|
+
aria-label="Previous step"
|
|
1459
|
+
disabled={activeStep === 0}
|
|
1460
|
+
onClick={() => {
|
|
1461
|
+
revealTouchNav();
|
|
1462
|
+
if (activeStep > 0) handlePrevious();
|
|
1463
|
+
}}
|
|
1464
|
+
>
|
|
1465
|
+
<ArrowLeftOutlined />
|
|
1466
|
+
</button>
|
|
1467
|
+
<button
|
|
1468
|
+
type="button"
|
|
1469
|
+
className={`steps-touch-nav steps-touch-nav-right${touchNavVisible ? ' is-visible' : ''}`}
|
|
1470
|
+
aria-label="Next step"
|
|
1471
|
+
disabled={activeStep >= steps.length - 1 || steps[activeStep]?.order_seqtype === 'E' || !isStepCompleted}
|
|
1472
|
+
onClick={() => {
|
|
1473
|
+
revealTouchNav();
|
|
1474
|
+
const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
|
|
1475
|
+
if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
|
|
1476
|
+
handleNext();
|
|
1477
|
+
}
|
|
1478
|
+
}}
|
|
1479
|
+
>
|
|
1480
|
+
<ArrowRightOutlined />
|
|
1481
|
+
</button>
|
|
1482
|
+
</>
|
|
1483
|
+
) : null}
|
|
1230
1484
|
<div
|
|
1231
1485
|
key={`${currentProcessId}_${activeStep}`}
|
|
1232
1486
|
className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
|
|
@@ -1245,7 +1499,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
1245
1499
|
<Spin />
|
|
1246
1500
|
</div>
|
|
1247
1501
|
) : null}
|
|
1248
|
-
{!loading ?
|
|
1502
|
+
{!loading ? renderDynamicStep() : null}
|
|
1249
1503
|
</div>
|
|
1250
1504
|
</div>
|
|
1251
1505
|
</div>
|
|
@@ -315,6 +315,177 @@
|
|
|
315
315
|
overflow: hidden;
|
|
316
316
|
padding: 10px 14px;
|
|
317
317
|
box-sizing: border-box;
|
|
318
|
+
position: relative;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.steps-stage-body.is-swipe-enabled {
|
|
322
|
+
touch-action: pan-y;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ── Touch-device floating nav arrows ───────────────────── */
|
|
326
|
+
|
|
327
|
+
.steps-touch-nav {
|
|
328
|
+
position: absolute;
|
|
329
|
+
top: 50%;
|
|
330
|
+
z-index: 6;
|
|
331
|
+
width: 52px;
|
|
332
|
+
height: 52px;
|
|
333
|
+
display: inline-flex;
|
|
334
|
+
align-items: center;
|
|
335
|
+
justify-content: center;
|
|
336
|
+
padding: 0;
|
|
337
|
+
border: none;
|
|
338
|
+
border-radius: 50%;
|
|
339
|
+
background: rgba(30, 58, 138, 0.55);
|
|
340
|
+
backdrop-filter: blur(8px) saturate(140%);
|
|
341
|
+
-webkit-backdrop-filter: blur(8px) saturate(140%);
|
|
342
|
+
color: #ffffff;
|
|
343
|
+
font-size: 18px;
|
|
344
|
+
cursor: pointer;
|
|
345
|
+
box-shadow:
|
|
346
|
+
0 10px 24px rgba(15, 23, 42, 0.22),
|
|
347
|
+
inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
|
348
|
+
opacity: 0;
|
|
349
|
+
visibility: hidden;
|
|
350
|
+
transform: translateY(-50%) scale(0.82);
|
|
351
|
+
transition:
|
|
352
|
+
opacity 260ms ease,
|
|
353
|
+
transform 260ms cubic-bezier(0.2, 0.8, 0.25, 1),
|
|
354
|
+
visibility 0s linear 260ms,
|
|
355
|
+
background-color 200ms ease,
|
|
356
|
+
box-shadow 200ms ease;
|
|
357
|
+
-webkit-tap-highlight-color: transparent;
|
|
358
|
+
touch-action: manipulation;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/*
|
|
362
|
+
Decorative pulsing ring around the button.
|
|
363
|
+
- Drawn via ::before so it does not interfere with click targets.
|
|
364
|
+
- Only animates while the button is visible and enabled, to keep the
|
|
365
|
+
idle state calm instead of visually noisy.
|
|
366
|
+
*/
|
|
367
|
+
.steps-touch-nav::before {
|
|
368
|
+
content: '';
|
|
369
|
+
position: absolute;
|
|
370
|
+
inset: 0;
|
|
371
|
+
border-radius: 50%;
|
|
372
|
+
border: 2px solid rgba(255, 255, 255, 0.45);
|
|
373
|
+
opacity: 0;
|
|
374
|
+
pointer-events: none;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.steps-touch-nav.is-visible:not(:disabled)::before {
|
|
378
|
+
animation: steps-touch-nav-ring 2.4s ease-out infinite;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/*
|
|
382
|
+
Radial wash behind the icon for depth.
|
|
383
|
+
- Keeps the icon glyph crisp against varied content backgrounds.
|
|
384
|
+
*/
|
|
385
|
+
.steps-touch-nav::after {
|
|
386
|
+
content: '';
|
|
387
|
+
position: absolute;
|
|
388
|
+
inset: 4px;
|
|
389
|
+
border-radius: 50%;
|
|
390
|
+
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 65%);
|
|
391
|
+
pointer-events: none;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.steps-touch-nav .anticon {
|
|
395
|
+
position: relative;
|
|
396
|
+
z-index: 1;
|
|
397
|
+
filter: drop-shadow(0 1px 1px rgba(15, 23, 42, 0.25));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.steps-touch-nav:hover:not(:disabled),
|
|
401
|
+
.steps-touch-nav:focus-visible:not(:disabled) {
|
|
402
|
+
background: rgba(30, 58, 138, 0.92);
|
|
403
|
+
box-shadow:
|
|
404
|
+
0 14px 30px rgba(15, 23, 42, 0.32),
|
|
405
|
+
inset 0 0 0 1px rgba(255, 255, 255, 0.28);
|
|
406
|
+
outline: none;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.steps-touch-nav:hover:not(:disabled),
|
|
410
|
+
.steps-touch-nav:focus-visible:not(:disabled),
|
|
411
|
+
.steps-touch-nav.is-visible:hover:not(:disabled),
|
|
412
|
+
.steps-touch-nav.is-visible:focus-visible:not(:disabled) {
|
|
413
|
+
opacity: 1;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.steps-touch-nav:active:not(:disabled) {
|
|
417
|
+
transform: translateY(-50%) scale(0.94);
|
|
418
|
+
transition:
|
|
419
|
+
transform 120ms ease,
|
|
420
|
+
opacity 120ms ease,
|
|
421
|
+
background-color 120ms ease,
|
|
422
|
+
box-shadow 120ms ease;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.steps-touch-nav:disabled {
|
|
426
|
+
background: rgba(148, 163, 184, 0.38);
|
|
427
|
+
color: rgba(255, 255, 255, 0.7);
|
|
428
|
+
cursor: not-allowed;
|
|
429
|
+
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
|
|
430
|
+
backdrop-filter: none;
|
|
431
|
+
-webkit-backdrop-filter: none;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.steps-touch-nav:disabled::after {
|
|
435
|
+
display: none;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.steps-touch-nav-left {
|
|
439
|
+
left: 14px;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.steps-touch-nav-right {
|
|
443
|
+
right: 14px;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.steps-touch-nav.is-visible {
|
|
447
|
+
opacity: 0.68;
|
|
448
|
+
visibility: visible;
|
|
449
|
+
transform: translateY(-50%) scale(1);
|
|
450
|
+
transition:
|
|
451
|
+
opacity 260ms ease,
|
|
452
|
+
transform 260ms cubic-bezier(0.2, 0.8, 0.25, 1),
|
|
453
|
+
visibility 0s linear 0s,
|
|
454
|
+
background-color 200ms ease,
|
|
455
|
+
box-shadow 200ms ease;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
@keyframes steps-touch-nav-ring {
|
|
459
|
+
0% {
|
|
460
|
+
opacity: 0.55;
|
|
461
|
+
transform: scale(1);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
65% {
|
|
465
|
+
opacity: 0;
|
|
466
|
+
transform: scale(1.45);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
100% {
|
|
470
|
+
opacity: 0;
|
|
471
|
+
transform: scale(1.45);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
@media (prefers-reduced-motion: reduce) {
|
|
476
|
+
.steps-touch-nav {
|
|
477
|
+
transition: opacity 120ms linear, visibility 0s linear 120ms;
|
|
478
|
+
transform: translateY(-50%);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.steps-touch-nav.is-visible {
|
|
482
|
+
transform: translateY(-50%);
|
|
483
|
+
transition: opacity 120ms linear, visibility 0s linear 0s;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.steps-touch-nav.is-visible:not(:disabled)::before {
|
|
487
|
+
animation: none;
|
|
488
|
+
}
|
|
318
489
|
}
|
|
319
490
|
|
|
320
491
|
.steps-stage-body::-webkit-scrollbar {
|
|
@@ -665,6 +836,20 @@
|
|
|
665
836
|
.steps-narration-bar {
|
|
666
837
|
padding: 8px 12px;
|
|
667
838
|
}
|
|
839
|
+
|
|
840
|
+
.steps-touch-nav {
|
|
841
|
+
width: 44px;
|
|
842
|
+
height: 44px;
|
|
843
|
+
font-size: 16px;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.steps-touch-nav-left {
|
|
847
|
+
left: 8px;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
.steps-touch-nav-right {
|
|
851
|
+
right: 8px;
|
|
852
|
+
}
|
|
668
853
|
}
|
|
669
854
|
|
|
670
855
|
/* ── Reduced motion ─────────────────────────────────────── */
|