@bytebrand/fe-ui-core-autobahn 1.0.151 → 1.0.152
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/cards/CarCard.tsx +384 -384
- package/compat/MdField/MdField.tsx +20 -2
- package/locales/de/autobahn.json +2 -1
- package/locales/en/autobahn.json +1 -0
- package/package.json +30 -30
- package/theme/md3.css +14179 -11409
- package/ui/Dropdown.tsx +13 -2
- package/ui/FormCheckbox.tsx +7 -0
- package/ui/FormField.tsx +2 -1
- package/ui/Icon.tsx +2 -0
- package/ui/MfieldCheckIcon.tsx +13 -0
- package/ui/Modal.tsx +73 -4
- package/ui/usePortalMenuPosition.ts +8 -2
- package/vdp/FinanceCalculator.tsx +19 -5
- package/vdp/VdpModules.tsx +4397 -4397
package/ui/Dropdown.tsx
CHANGED
|
@@ -59,8 +59,16 @@ export interface DropdownProps {
|
|
|
59
59
|
ariaDescribedBy?: string;
|
|
60
60
|
/** extra class on the wrapper (e.g. 'srp-sort' for width rules). */
|
|
61
61
|
className?: string;
|
|
62
|
+
/** extra class on the popover menu itself (`.mselect-menu`) — separate from `className` since
|
|
63
|
+
* with `menuPortal` the menu renders at document.body, outside the trigger wrapper's subtree,
|
|
64
|
+
* so a caller that needs to style its OWN options (`.mselect-opt`) needs this to scope there. */
|
|
65
|
+
menuClassName?: string;
|
|
62
66
|
/** align the popover to the right edge of the trigger (default left). */
|
|
63
67
|
menuAlign?: 'left' | 'right';
|
|
68
|
+
/** with `menuPortal`: 'auto' (default) flips the popover above the trigger when there's no
|
|
69
|
+
* room below; 'down' always opens below, never flips — for a trigger where an upward flip
|
|
70
|
+
* would overlap unrelated content above it. */
|
|
71
|
+
menuOpenDirection?: 'auto' | 'down';
|
|
64
72
|
/** show a text input at the top of the popover that filters `options` by label (substring, case-insensitive). */
|
|
65
73
|
searchable?: boolean;
|
|
66
74
|
/** placeholder for the search input (only used when `searchable`). */
|
|
@@ -97,7 +105,9 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
97
105
|
ariaInvalid,
|
|
98
106
|
ariaDescribedBy,
|
|
99
107
|
className,
|
|
108
|
+
menuClassName,
|
|
100
109
|
menuAlign = 'left',
|
|
110
|
+
menuOpenDirection = 'auto',
|
|
101
111
|
searchable = false,
|
|
102
112
|
searchPlaceholder,
|
|
103
113
|
numeric = false,
|
|
@@ -221,6 +231,7 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
221
231
|
open,
|
|
222
232
|
rootRef,
|
|
223
233
|
menuAlign,
|
|
234
|
+
openDirection: menuOpenDirection,
|
|
224
235
|
onCloseAway,
|
|
225
236
|
});
|
|
226
237
|
|
|
@@ -400,7 +411,7 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
400
411
|
? ReactDOM.createPortal(
|
|
401
412
|
<div
|
|
402
413
|
ref={menuElRef}
|
|
403
|
-
className={'mselect-menu mselect-menu--portal' + (searchable ? ' mselect-menu--searchable' : '')}
|
|
414
|
+
className={'mselect-menu mselect-menu--portal' + (searchable ? ' mselect-menu--searchable' : '') + (menuClassName ? ` ${menuClassName}` : '')}
|
|
404
415
|
// menuPos is null for one frame on open, before the position effect has measured
|
|
405
416
|
// the trigger — stay invisible (not just visually, via `visibility`) rather than
|
|
406
417
|
// flash at (0,0)/full-width. `position: fixed` is set UNCONDITIONALLY, in both
|
|
@@ -453,7 +464,7 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
453
464
|
: (
|
|
454
465
|
<div
|
|
455
466
|
ref={menuElRef}
|
|
456
|
-
className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}
|
|
467
|
+
className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '') + (menuClassName ? ` ${menuClassName}` : '')}
|
|
457
468
|
>
|
|
458
469
|
{renderMenuList()}
|
|
459
470
|
</div>
|
package/ui/FormCheckbox.tsx
CHANGED
|
@@ -42,6 +42,13 @@ const FormCheckbox: React.FunctionComponent<FormCheckboxProps> = observer(
|
|
|
42
42
|
({ field, t, label, children, disabled, id }) => {
|
|
43
43
|
const isChecked = !!field.checked;
|
|
44
44
|
const isDisabled = disabled != null ? disabled : !!field.disabled;
|
|
45
|
+
// field.hasError alone is a LIVE check (mobx-react-form's Field#hasError getter re-evaluates
|
|
46
|
+
// the rule against the current value on every read) — true for an unchecked required field
|
|
47
|
+
// from initial render, before the user ever interacts. field.error is what's actually gated
|
|
48
|
+
// behind the field's showError flag (Field#error getter), which only flips true once
|
|
49
|
+
// showErrors()/an explicit validate({showErrors:true}) runs — the real "has this been
|
|
50
|
+
// submitted/touched" signal. Keep requiring both, or every required checkbox on the site
|
|
51
|
+
// renders red on page load.
|
|
45
52
|
const hasError = !!field.hasError && !!field.error;
|
|
46
53
|
|
|
47
54
|
// children (rich) > explicit label > translated field.label (legacy CheckboxMaterial behaviour).
|
package/ui/FormField.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import * as React from 'react';
|
|
|
12
12
|
import { observer } from 'mobx-react';
|
|
13
13
|
|
|
14
14
|
import Icon from './Icon';
|
|
15
|
+
import MfieldCheckIcon from './MfieldCheckIcon';
|
|
15
16
|
|
|
16
17
|
export interface FormFieldProps {
|
|
17
18
|
/** mobx-react-form field (LoginForm.$('email') etc.). Typed any — the lib ships no TS types. */
|
|
@@ -82,7 +83,7 @@ const FormField: React.FunctionComponent<FormFieldProps> = observer(
|
|
|
82
83
|
<Icon name={reveal ? 'eye-off' : 'eye'} size={18} />
|
|
83
84
|
</button>
|
|
84
85
|
) : null}
|
|
85
|
-
{isValid ? <span className="mfield-ok"><
|
|
86
|
+
{isValid ? <span className="mfield-ok"><MfieldCheckIcon /></span> : null}
|
|
86
87
|
</div>
|
|
87
88
|
{hasError ? <span className="mfield-error-text">{field.error}</span> : null}
|
|
88
89
|
</div>
|
package/ui/Icon.tsx
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
DoorOpen,
|
|
52
52
|
Droplet,
|
|
53
53
|
Euro,
|
|
54
|
+
ExternalLink,
|
|
54
55
|
Eye,
|
|
55
56
|
EyeOff,
|
|
56
57
|
Facebook,
|
|
@@ -249,6 +250,7 @@ const REGISTRY: Record<string, React.ComponentType<LucideProps>> = {
|
|
|
249
250
|
DoorOpen,
|
|
250
251
|
Droplet,
|
|
251
252
|
Euro,
|
|
253
|
+
ExternalLink,
|
|
252
254
|
Eye,
|
|
253
255
|
EyeOff,
|
|
254
256
|
Facebook,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
// Figma-exact valid-field checkmark (.mfield-ok, FormField/MdField's showValid) — a distinct
|
|
4
|
+
// path/stroke from the generic lucide Check that Icon name="check" renders elsewhere, so it's
|
|
5
|
+
// its own tiny component rather than a REGISTRY entry in Icon.tsx (which would change every
|
|
6
|
+
// other "check" usage in the app too).
|
|
7
|
+
const MfieldCheckIcon: React.FunctionComponent = () => (
|
|
8
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
9
|
+
<path d="M13.3334 4L6.00002 11.3333L2.66669 8" stroke="#168A45" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
10
|
+
</svg>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export default MfieldCheckIcon;
|
package/ui/Modal.tsx
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// - scrim ~50% black; body scroll-locked while open; basic focus trap (Tab/Shift+Tab
|
|
16
16
|
// cycle within the surface, focus moves into the dialog on open and is restored to the
|
|
17
17
|
// previously-focused element on close).
|
|
18
|
+
// - mobile bottom sheet (≤560px): swipe down on the head row to dismiss (mirrors fe-main's
|
|
19
|
+
// BaseModal.tsx sheet), when dismissOnScrim is true.
|
|
18
20
|
// - a11y: role=dialog, aria-modal, aria-labelledby wired to the title when present.
|
|
19
21
|
//
|
|
20
22
|
// Pure/presentational — no store/API access beyond document/body (all SSR-guarded). The
|
|
@@ -44,6 +46,11 @@ export interface ModalProps {
|
|
|
44
46
|
dismissOnScrim?: boolean;
|
|
45
47
|
/** extra class(es) appended on the surface (layout hooks). */
|
|
46
48
|
className?: string;
|
|
49
|
+
/** custom close-button content, replacing the default IconButton(name="x"). When set, it
|
|
50
|
+
* renders as a plain button.mmodal-close instead (own CSS handles sizing/position — see
|
|
51
|
+
* md3.css), so callers with a specific close-icon spec (exact asset, absolute position
|
|
52
|
+
* independent of title height) aren't stuck fighting the default's flex layout. */
|
|
53
|
+
closeIcon?: React.ReactNode;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
// focusable descendants used by the basic focus trap.
|
|
@@ -59,10 +66,47 @@ const Modal: React.FunctionComponent<ModalProps> = ({
|
|
|
59
66
|
size = 'md',
|
|
60
67
|
dismissOnScrim = true,
|
|
61
68
|
className,
|
|
69
|
+
closeIcon,
|
|
62
70
|
}) => {
|
|
63
71
|
const surfaceRef = React.useRef<HTMLDivElement | null>(null);
|
|
64
72
|
// the element focused before the dialog opened, so focus can be restored on close.
|
|
65
73
|
const prevFocusRef = React.useRef<HTMLElement | null>(null);
|
|
74
|
+
// swipe-down-to-dismiss on the mobile bottom sheet (≤560px, matching the .mmodal-scrim/
|
|
75
|
+
// .mmodal breakpoint in md3.css) — same imperative-transform pattern as fe-main's
|
|
76
|
+
// BaseModal.tsx (checkout's own bottom sheet): follows the finger directly via
|
|
77
|
+
// style.transform while dragging (no re-renders), released past 90px slides out and closes,
|
|
78
|
+
// otherwise springs back. No-ops above the breakpoint and when dismissOnScrim is false (a
|
|
79
|
+
// modal that can't be scrim-dismissed shouldn't be swipe-dismissable either).
|
|
80
|
+
const dragStartY = React.useRef<number | null>(null);
|
|
81
|
+
|
|
82
|
+
const onGrabStart = (event: React.TouchEvent) => {
|
|
83
|
+
if (!dismissOnScrim) return;
|
|
84
|
+
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 561px)').matches) return;
|
|
85
|
+
dragStartY.current = event.touches[0].clientY;
|
|
86
|
+
if (surfaceRef.current) surfaceRef.current.style.transition = 'none';
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const onGrabMove = (event: React.TouchEvent) => {
|
|
90
|
+
if (dragStartY.current == null || !surfaceRef.current) return;
|
|
91
|
+
const dy = event.touches[0].clientY - dragStartY.current;
|
|
92
|
+
// only follow downward drags — the sheet is already flush with the bottom of the
|
|
93
|
+
// viewport, so upward has nowhere to go.
|
|
94
|
+
surfaceRef.current.style.transform = dy > 0 ? `translateY(${dy}px)` : '';
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const onGrabEnd = (event: React.TouchEvent) => {
|
|
98
|
+
if (dragStartY.current == null || !surfaceRef.current) return;
|
|
99
|
+
const dy = event.changedTouches[0].clientY - dragStartY.current;
|
|
100
|
+
dragStartY.current = null;
|
|
101
|
+
const el = surfaceRef.current;
|
|
102
|
+
el.style.transition = 'transform 0.2s ease-out';
|
|
103
|
+
if (dy > 90) {
|
|
104
|
+
el.style.transform = 'translateY(100%)';
|
|
105
|
+
window.setTimeout(onClose, 200);
|
|
106
|
+
} else {
|
|
107
|
+
el.style.transform = '';
|
|
108
|
+
}
|
|
109
|
+
};
|
|
66
110
|
// stable id for aria-labelledby (avoids Math.random/Date.now, unavailable in this env).
|
|
67
111
|
const titleId = React.useMemo(
|
|
68
112
|
() => (title ? `mmodal-title-${Math.round((surfaceRef.current ? 1 : 0))}-${size}` : undefined),
|
|
@@ -90,10 +134,20 @@ const Modal: React.FunctionComponent<ModalProps> = ({
|
|
|
90
134
|
document.body.style.overflow = 'hidden';
|
|
91
135
|
|
|
92
136
|
// move focus into the dialog (first focusable, else the surface itself).
|
|
137
|
+
// { preventScroll: true }: this runs at the very first animation frame of the mobile
|
|
138
|
+
// sheet's own mmodal-sheet-up entrance (md3.css) — at that instant the surface's actual
|
|
139
|
+
// computed transform is still the keyframe's `from` state (translateY(100%), pushed
|
|
140
|
+
// off-screen below the viewport), not its settled position yet. A plain .focus() on an
|
|
141
|
+
// element the browser currently considers off-screen makes it auto-scroll the nearest
|
|
142
|
+
// scrollable ancestor (.mmodal-scrim, itself overflow-y:auto) to bring it into view —
|
|
143
|
+
// fighting the CSS slide-in transform animating at the same time, which is exactly what
|
|
144
|
+
// produces a double-motion "jumps up, then settles back down" glitch on open. preventScroll
|
|
145
|
+
// skips that native auto-scroll entirely, so the CSS animation alone moves the sheet
|
|
146
|
+
// (same fix as fe-main's BaseModal.tsx, which hit this same glitch first).
|
|
93
147
|
const surface = surfaceRef.current;
|
|
94
148
|
if (surface) {
|
|
95
149
|
const first = surface.querySelector<HTMLElement>(FOCUSABLE);
|
|
96
|
-
(first || surface).focus();
|
|
150
|
+
(first || surface).focus({ preventScroll: true });
|
|
97
151
|
}
|
|
98
152
|
|
|
99
153
|
return () => {
|
|
@@ -165,8 +219,17 @@ const Modal: React.FunctionComponent<ModalProps> = ({
|
|
|
165
219
|
tabIndex={-1}
|
|
166
220
|
onMouseDown={(e) => e.stopPropagation()}
|
|
167
221
|
>
|
|
168
|
-
{/* head row always renders so the close button is always present.
|
|
169
|
-
|
|
222
|
+
{/* head row always renders so the close button is always present. onTouch*: swipe-
|
|
223
|
+
down-to-dismiss grab zone (see onGrabStart/Move/End above) — a tap (near-zero dy)
|
|
224
|
+
never crosses the 90px threshold and nothing here calls preventDefault, so the
|
|
225
|
+
close button's own onClick still fires normally after touchend. */}
|
|
226
|
+
<div
|
|
227
|
+
className="mmodal-head"
|
|
228
|
+
onTouchStart={onGrabStart}
|
|
229
|
+
onTouchMove={onGrabMove}
|
|
230
|
+
onTouchEnd={onGrabEnd}
|
|
231
|
+
style={{ touchAction: 'none' }}
|
|
232
|
+
>
|
|
170
233
|
{title ? (
|
|
171
234
|
<h2 id={titleId} className="mmodal-title md-headline-sm">
|
|
172
235
|
{title}
|
|
@@ -174,7 +237,13 @@ const Modal: React.FunctionComponent<ModalProps> = ({
|
|
|
174
237
|
) : (
|
|
175
238
|
<span className="mmodal-title" aria-hidden="true" />
|
|
176
239
|
)}
|
|
177
|
-
|
|
240
|
+
{closeIcon ? (
|
|
241
|
+
<button type="button" className="mmodal-close" aria-label="Schließen" onClick={onClose}>
|
|
242
|
+
{closeIcon}
|
|
243
|
+
</button>
|
|
244
|
+
) : (
|
|
245
|
+
<IconButton name="x" label="Schließen" onClick={onClose} />
|
|
246
|
+
)}
|
|
178
247
|
</div>
|
|
179
248
|
<div className="mmodal-body">{children}</div>
|
|
180
249
|
{footer ? <div className="mmodal-foot">{footer}</div> : null}
|
|
@@ -45,6 +45,11 @@ export interface UsePortalMenuPositionArgs {
|
|
|
45
45
|
/** the trigger's wrapping element — always rendered (regardless of `open`), measured for position. */
|
|
46
46
|
rootRef: React.RefObject<HTMLElement>;
|
|
47
47
|
menuAlign?: 'left' | 'right';
|
|
48
|
+
/** 'auto' (default): flips ABOVE the trigger when there isn't room below (see ESTIMATED_MENU_HEIGHT
|
|
49
|
+
* below). 'down': always opens below, never flips — for a trigger where the caller knows the
|
|
50
|
+
* page has room below and an upward flip would overlap unrelated content above it (e.g. a
|
|
51
|
+
* trigger sitting right under a sticky/close heading). */
|
|
52
|
+
openDirection?: 'auto' | 'down';
|
|
48
53
|
/** called when the trigger scrolls out of view entirely, or a real user scroll happens after
|
|
49
54
|
* the post-open grace window — the menu has nothing sensible left to anchor to or chase. */
|
|
50
55
|
onCloseAway: () => void;
|
|
@@ -79,6 +84,7 @@ export function usePortalMenuPosition<TMenuEl extends HTMLElement = HTMLDivEleme
|
|
|
79
84
|
open,
|
|
80
85
|
rootRef,
|
|
81
86
|
menuAlign = 'left',
|
|
87
|
+
openDirection = 'auto',
|
|
82
88
|
onCloseAway,
|
|
83
89
|
}: UsePortalMenuPositionArgs): UsePortalMenuPositionResult<TMenuEl> {
|
|
84
90
|
const menuElRef = React.useRef<TMenuEl | null>(null);
|
|
@@ -144,7 +150,7 @@ export function usePortalMenuPosition<TMenuEl extends HTMLElement = HTMLDivEleme
|
|
|
144
150
|
}
|
|
145
151
|
const spaceBelow = window.innerHeight - rect.bottom - GAP;
|
|
146
152
|
const spaceAbove = rect.top - safeTop - GAP;
|
|
147
|
-
const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow;
|
|
153
|
+
const openAbove = openDirection === 'down' ? false : (spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow);
|
|
148
154
|
// cap the menu to the space it ACTUALLY has in whichever direction it opens, instead of
|
|
149
155
|
// trusting a static CSS max-height on its own — near the header or viewport edge that static
|
|
150
156
|
// cap can still be taller than what's really available, which is what let the menu paint over
|
|
@@ -159,7 +165,7 @@ export function usePortalMenuPosition<TMenuEl extends HTMLElement = HTMLDivEleme
|
|
|
159
165
|
bottom: openAbove ? window.innerHeight - rect.top + GAP : 'auto',
|
|
160
166
|
maxHeight: available,
|
|
161
167
|
});
|
|
162
|
-
}, [enabled, menuAlign, onCloseAway, rootRef]);
|
|
168
|
+
}, [enabled, menuAlign, openDirection, onCloseAway, rootRef]);
|
|
163
169
|
|
|
164
170
|
// Synchronous (pre-paint) position computation. useLayoutEffect — not useEffect — matters here:
|
|
165
171
|
// it runs right after the DOM mutation that mounted the portal but BEFORE the browser paints, so
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Renders correctly only inside a `<div className="md3">` subtree.
|
|
15
15
|
|
|
16
|
-
import React, { useState } from 'react';
|
|
16
|
+
import React, { useState, useEffect } from 'react';
|
|
17
17
|
import { TranslationFunction } from '../types';
|
|
18
18
|
|
|
19
19
|
import { Button, Icon, Switch } from '../ui';
|
|
@@ -52,8 +52,18 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
|
|
|
52
52
|
bubble,
|
|
53
53
|
bubblePaddingBottom = 34,
|
|
54
54
|
}) => {
|
|
55
|
+
// Local mirror of `value`, updated INSTANTLY on every native input event. Callers commonly
|
|
56
|
+
// debounce their own onChange (e.g. CheckoutFinancePanelNext's 50ms setCommonUserConfig,
|
|
57
|
+
// which also triggers a MobX-driven re-render of a much bigger subtree) — without this, the
|
|
58
|
+
// thumb still drags natively/smoothly (the browser owns that), but the gradient fill and any
|
|
59
|
+
// bubble label are driven by the `value` PROP, which only catches up once the debounced commit
|
|
60
|
+
// finally lands, so the fill visibly lags/stutters behind the thumb during a fast drag. Synced
|
|
61
|
+
// back to the prop via useEffect for externally-driven changes (e.g. a reset button).
|
|
62
|
+
const [localValue, setLocalValue] = useState(value);
|
|
63
|
+
useEffect(() => { setLocalValue(value); }, [value]);
|
|
64
|
+
|
|
55
65
|
const span = max - min;
|
|
56
|
-
const pct = span > 0 ? ((
|
|
66
|
+
const pct = span > 0 ? ((localValue - min) / span) * 100 : 0;
|
|
57
67
|
return (
|
|
58
68
|
<div>
|
|
59
69
|
<div style={{ position: 'relative', paddingTop: bubble ? 44 : 0, paddingBottom: bubble ? bubblePaddingBottom : 0 }}>
|
|
@@ -61,7 +71,7 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
|
|
|
61
71
|
// thumb is 22px wide: interpolate the bubble anchor between half-thumb insets so it
|
|
62
72
|
// tracks the thumb center (a plain pct% drifts at the rail ends)
|
|
63
73
|
<span className="slider-bubble num" style={{ left: `calc(11px + (100% - 22px) * ${pct / 100})` }}>
|
|
64
|
-
{fmt(
|
|
74
|
+
{fmt(localValue)}
|
|
65
75
|
</span>
|
|
66
76
|
)}
|
|
67
77
|
<input
|
|
@@ -70,8 +80,12 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
|
|
|
70
80
|
min={min}
|
|
71
81
|
max={max}
|
|
72
82
|
step={step}
|
|
73
|
-
value={
|
|
74
|
-
onChange={(e) =>
|
|
83
|
+
value={localValue}
|
|
84
|
+
onChange={(e) => {
|
|
85
|
+
const v = Number(e.target.value);
|
|
86
|
+
setLocalValue(v);
|
|
87
|
+
onChange(v);
|
|
88
|
+
}}
|
|
75
89
|
style={{ background: `linear-gradient(90deg, var(--md-primary) ${pct}%, var(--md-sc-highest) ${pct}%)` }}
|
|
76
90
|
/>
|
|
77
91
|
</div>
|