@pagopa/io-app-design-system 6.0.4 → 6.0.5
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/lib/commonjs/components/claimsSelector/__test__/__snapshots__/ClaimsSelector.test.tsx.snap +7 -5
- package/lib/commonjs/components/listitems/ListItemInfo.js +86 -26
- package/lib/commonjs/components/listitems/ListItemInfo.js.map +1 -1
- package/lib/commonjs/components/listitems/__test__/__snapshots__/listitem.test.tsx.snap +6 -4
- package/lib/commonjs/components/loadingSpinner/LoadingSpinner.js +30 -23
- package/lib/commonjs/components/loadingSpinner/LoadingSpinner.js.map +1 -1
- package/lib/module/components/claimsSelector/__test__/__snapshots__/ClaimsSelector.test.tsx.snap +7 -5
- package/lib/module/components/listitems/ListItemInfo.js +87 -27
- package/lib/module/components/listitems/ListItemInfo.js.map +1 -1
- package/lib/module/components/listitems/__test__/__snapshots__/listitem.test.tsx.snap +6 -4
- package/lib/module/components/loadingSpinner/LoadingSpinner.js +30 -24
- package/lib/module/components/loadingSpinner/LoadingSpinner.js.map +1 -1
- package/lib/typescript/components/listitems/ListItemInfo.d.ts +29 -0
- package/lib/typescript/components/listitems/ListItemInfo.d.ts.map +1 -1
- package/lib/typescript/components/loadingSpinner/LoadingSpinner.d.ts +2 -3
- package/lib/typescript/components/loadingSpinner/LoadingSpinner.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/claimsSelector/__test__/__snapshots__/ClaimsSelector.test.tsx.snap +7 -5
- package/src/components/listitems/ListItemInfo.tsx +124 -49
- package/src/components/listitems/__test__/__snapshots__/listitem.test.tsx.snap +6 -4
- package/src/components/loadingSpinner/LoadingSpinner.tsx +26 -33
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ComponentProps, ReactNode } from "react";
|
|
2
|
-
import { AccessibilityRole,
|
|
2
|
+
import { AccessibilityRole, Pressable, View } from "react-native";
|
|
3
3
|
import Animated from "react-native-reanimated";
|
|
4
4
|
import { useIOTheme } from "../../context";
|
|
5
5
|
import { IOListItemStyles, IOListItemVisualParams } from "../../core";
|
|
@@ -35,14 +35,8 @@ type EndElementProps =
|
|
|
35
35
|
| BadgeProps;
|
|
36
36
|
|
|
37
37
|
type GraphicProps =
|
|
38
|
-
| {
|
|
39
|
-
|
|
40
|
-
icon?: never;
|
|
41
|
-
}
|
|
42
|
-
| {
|
|
43
|
-
paymentLogoIcon?: never;
|
|
44
|
-
icon?: IOIcons;
|
|
45
|
-
};
|
|
38
|
+
| { paymentLogoIcon?: IOLogoPaymentType; icon?: never }
|
|
39
|
+
| { paymentLogoIcon?: never; icon?: IOIcons };
|
|
46
40
|
|
|
47
41
|
type InteractiveProps = Pick<
|
|
48
42
|
ComponentProps<typeof Pressable>,
|
|
@@ -55,7 +49,6 @@ export type ListItemInfo = WithTestID<{
|
|
|
55
49
|
numberOfLines?: number;
|
|
56
50
|
endElement?: EndElementProps;
|
|
57
51
|
topElement?: BadgeProps;
|
|
58
|
-
// Accessibility
|
|
59
52
|
accessibilityLabel?: string;
|
|
60
53
|
accessibilityRole?: AccessibilityRole;
|
|
61
54
|
reversed?: boolean;
|
|
@@ -72,16 +65,16 @@ const EndElementComponent = ({ type, componentProps }: EndElementProps) => {
|
|
|
72
65
|
<IOButton
|
|
73
66
|
variant="link"
|
|
74
67
|
{...componentProps}
|
|
75
|
-
accessibilityLabel={
|
|
68
|
+
accessibilityLabel={
|
|
76
69
|
componentProps.accessibilityLabel ?? componentProps.label
|
|
77
|
-
}
|
|
70
|
+
}
|
|
78
71
|
/>
|
|
79
72
|
);
|
|
80
73
|
case "iconButton":
|
|
81
74
|
return (
|
|
82
75
|
<IconButton
|
|
83
76
|
{...componentProps}
|
|
84
|
-
accessibilityLabel={
|
|
77
|
+
accessibilityLabel={componentProps.accessibilityLabel}
|
|
85
78
|
/>
|
|
86
79
|
);
|
|
87
80
|
case "badge":
|
|
@@ -99,7 +92,9 @@ const ListItemInfoContent = ({
|
|
|
99
92
|
numberOfLines,
|
|
100
93
|
reversed,
|
|
101
94
|
topElement,
|
|
102
|
-
endElement
|
|
95
|
+
endElement,
|
|
96
|
+
hasInteractiveElements,
|
|
97
|
+
listItemAccessibilityLabel
|
|
103
98
|
}: Pick<
|
|
104
99
|
ListItemInfo,
|
|
105
100
|
| "icon"
|
|
@@ -110,7 +105,10 @@ const ListItemInfoContent = ({
|
|
|
110
105
|
| "reversed"
|
|
111
106
|
| "topElement"
|
|
112
107
|
| "endElement"
|
|
113
|
-
>
|
|
108
|
+
> & {
|
|
109
|
+
hasInteractiveElements: boolean;
|
|
110
|
+
listItemAccessibilityLabel?: string;
|
|
111
|
+
}) => {
|
|
114
112
|
const theme = useIOTheme();
|
|
115
113
|
const { hugeFontEnabled } = useIOFontDynamicScale();
|
|
116
114
|
|
|
@@ -124,15 +122,23 @@ const ListItemInfoContent = ({
|
|
|
124
122
|
size={IOListItemVisualParams.iconSize}
|
|
125
123
|
/>
|
|
126
124
|
)}
|
|
125
|
+
|
|
127
126
|
{paymentLogoIcon && (
|
|
128
127
|
<LogoPaymentWithFallback
|
|
129
128
|
brand={paymentLogoIcon}
|
|
130
129
|
size={PAYMENT_LOGO_SIZE}
|
|
131
130
|
/>
|
|
132
131
|
)}
|
|
132
|
+
|
|
133
133
|
<View style={{ flex: 1 }}>
|
|
134
134
|
<View
|
|
135
|
-
accessible={
|
|
135
|
+
accessible={hasInteractiveElements}
|
|
136
|
+
accessibilityLabel={
|
|
137
|
+
hasInteractiveElements ? listItemAccessibilityLabel : undefined
|
|
138
|
+
}
|
|
139
|
+
importantForAccessibility={
|
|
140
|
+
hasInteractiveElements ? "yes" : "no-hide-descendants"
|
|
141
|
+
}
|
|
136
142
|
style={{ flexDirection: reversed ? "column-reverse" : "column" }}
|
|
137
143
|
>
|
|
138
144
|
{topElement?.type === "badge" && (
|
|
@@ -141,11 +147,13 @@ const ListItemInfoContent = ({
|
|
|
141
147
|
<VSpacer size={4} />
|
|
142
148
|
</View>
|
|
143
149
|
)}
|
|
150
|
+
|
|
144
151
|
{label && (
|
|
145
152
|
<BodySmall weight="Regular" color={theme["textBody-tertiary"]}>
|
|
146
153
|
{label}
|
|
147
154
|
</BodySmall>
|
|
148
155
|
)}
|
|
156
|
+
|
|
149
157
|
{typeof value === "string" ? (
|
|
150
158
|
<H6 color={theme["textBody-default"]} numberOfLines={numberOfLines}>
|
|
151
159
|
{value}
|
|
@@ -155,8 +163,14 @@ const ListItemInfoContent = ({
|
|
|
155
163
|
)}
|
|
156
164
|
</View>
|
|
157
165
|
</View>
|
|
166
|
+
|
|
158
167
|
{endElement && (
|
|
159
|
-
<View
|
|
168
|
+
<View
|
|
169
|
+
accessible={false}
|
|
170
|
+
importantForAccessibility={
|
|
171
|
+
hasInteractiveElements ? "auto" : "no-hide-descendants"
|
|
172
|
+
}
|
|
173
|
+
>
|
|
160
174
|
<EndElementComponent {...endElement} />
|
|
161
175
|
</View>
|
|
162
176
|
)}
|
|
@@ -164,6 +178,35 @@ const ListItemInfoContent = ({
|
|
|
164
178
|
);
|
|
165
179
|
};
|
|
166
180
|
|
|
181
|
+
/**
|
|
182
|
+
* ListItemInfo component displays information in a list item format with optional icons,
|
|
183
|
+
* labels, values, and end elements (buttons, badges).
|
|
184
|
+
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* **Accessibility for Interactive Elements:**
|
|
187
|
+
* When using interactive end elements (`buttonLink` or `iconButton`), you must provide
|
|
188
|
+
* an appropriate `accessibilityLabel` directly to the interactive component props.
|
|
189
|
+
* This ensures that screen reader users can understand the relationship between the
|
|
190
|
+
* list item content and the action that the interactive element triggers.
|
|
191
|
+
*
|
|
192
|
+
* Example:
|
|
193
|
+
* ```tsx
|
|
194
|
+
* <ListItemInfo
|
|
195
|
+
* label="Email"
|
|
196
|
+
* value="user@example.com"
|
|
197
|
+
* endElement={{
|
|
198
|
+
* type: "buttonLink",
|
|
199
|
+
* componentProps: {
|
|
200
|
+
* label: "Edit",
|
|
201
|
+
* accessibilityLabel: "Edit email address"
|
|
202
|
+
* }
|
|
203
|
+
* }}
|
|
204
|
+
* />
|
|
205
|
+
* ```
|
|
206
|
+
*
|
|
207
|
+
* The design system cannot enforce this pattern automatically, so it's the responsibility
|
|
208
|
+
* of the implementing software engineer to ensure proper accessibility labels are set.
|
|
209
|
+
*/
|
|
167
210
|
export const ListItemInfo = ({
|
|
168
211
|
value,
|
|
169
212
|
label,
|
|
@@ -185,13 +228,41 @@ export const ListItemInfo = ({
|
|
|
185
228
|
const { onPressIn, onPressOut, scaleAnimatedStyle, backgroundAnimatedStyle } =
|
|
186
229
|
useListItemAnimation();
|
|
187
230
|
|
|
231
|
+
/**
|
|
232
|
+
* A11Y Support: Two different combinations based on interactive elements
|
|
233
|
+
*
|
|
234
|
+
* 1. NO interactive elements (or just badge):
|
|
235
|
+
* - The outer container is accessible and receives the complete accessibility label
|
|
236
|
+
* - This allows the entire list item to be treated as a single accessibility element
|
|
237
|
+
*
|
|
238
|
+
* 2. WITH interactive elements (buttonLink or iconButton):
|
|
239
|
+
* - The outer container is NOT accessible
|
|
240
|
+
* - The inner content becomes accessible with its label
|
|
241
|
+
* - The interactive element is separately accessible with its own label
|
|
242
|
+
* - This allows screen readers to navigate between the content and the action separately
|
|
243
|
+
*/
|
|
244
|
+
const hasInteractiveElements =
|
|
245
|
+
endElement?.type === "buttonLink" || endElement?.type === "iconButton";
|
|
246
|
+
|
|
188
247
|
const componentValueToAccessibility = typeof value === "string" ? value : "";
|
|
189
248
|
|
|
249
|
+
const topBadgeText =
|
|
250
|
+
topElement?.type === "badge" ? topElement.componentProps.text ?? "" : "";
|
|
251
|
+
|
|
252
|
+
const endBadgeText =
|
|
253
|
+
endElement?.type === "badge" ? endElement.componentProps.text ?? "" : "";
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build text in VISUAL ORDER
|
|
257
|
+
*/
|
|
258
|
+
const mainTextParts = reversed
|
|
259
|
+
? [componentValueToAccessibility, label]
|
|
260
|
+
: [label, componentValueToAccessibility];
|
|
261
|
+
|
|
262
|
+
const textParts = [topBadgeText, ...mainTextParts, endBadgeText];
|
|
263
|
+
|
|
190
264
|
const listItemAccessibilityLabel =
|
|
191
|
-
accessibilityLabel ??
|
|
192
|
-
(label
|
|
193
|
-
? `${label}; ${componentValueToAccessibility}`
|
|
194
|
-
: componentValueToAccessibility);
|
|
265
|
+
accessibilityLabel ?? textParts.filter(Boolean).join("; ");
|
|
195
266
|
|
|
196
267
|
const contentProps = {
|
|
197
268
|
icon,
|
|
@@ -201,7 +272,9 @@ export const ListItemInfo = ({
|
|
|
201
272
|
numberOfLines,
|
|
202
273
|
reversed,
|
|
203
274
|
topElement,
|
|
204
|
-
endElement
|
|
275
|
+
endElement,
|
|
276
|
+
hasInteractiveElements,
|
|
277
|
+
listItemAccessibilityLabel
|
|
205
278
|
} as const;
|
|
206
279
|
|
|
207
280
|
if (onLongPress) {
|
|
@@ -209,14 +282,14 @@ export const ListItemInfo = ({
|
|
|
209
282
|
<Pressable
|
|
210
283
|
onLongPress={onLongPress}
|
|
211
284
|
testID={testID}
|
|
212
|
-
accessible
|
|
213
|
-
|
|
214
|
-
onPressOut={onPressOut}
|
|
215
|
-
onTouchEnd={onPressOut}
|
|
216
|
-
accessibilityRole={"button"}
|
|
285
|
+
accessible
|
|
286
|
+
accessibilityRole="button"
|
|
217
287
|
accessibilityLabel={listItemAccessibilityLabel}
|
|
218
288
|
accessibilityActions={accessibilityActions}
|
|
219
289
|
onAccessibilityAction={onAccessibilityAction}
|
|
290
|
+
onPressIn={onPressIn}
|
|
291
|
+
onPressOut={onPressOut}
|
|
292
|
+
onTouchEnd={onPressOut}
|
|
220
293
|
>
|
|
221
294
|
<Animated.View
|
|
222
295
|
style={[IOListItemStyles.listItem, backgroundAnimatedStyle]}
|
|
@@ -238,29 +311,31 @@ export const ListItemInfo = ({
|
|
|
238
311
|
</Animated.View>
|
|
239
312
|
</Pressable>
|
|
240
313
|
);
|
|
241
|
-
}
|
|
242
|
-
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<View
|
|
318
|
+
style={IOListItemStyles.listItem}
|
|
319
|
+
testID={testID}
|
|
320
|
+
accessible={!hasInteractiveElements}
|
|
321
|
+
accessibilityLabel={
|
|
322
|
+
hasInteractiveElements ? undefined : listItemAccessibilityLabel
|
|
323
|
+
}
|
|
324
|
+
accessibilityRole={hasInteractiveElements ? undefined : accessibilityRole}
|
|
325
|
+
>
|
|
243
326
|
<View
|
|
244
|
-
style={
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
327
|
+
style={[
|
|
328
|
+
IOListItemStyles.listItemInner,
|
|
329
|
+
{
|
|
330
|
+
columnGap:
|
|
331
|
+
IOListItemVisualParams.iconMargin *
|
|
332
|
+
dynamicFontScale *
|
|
333
|
+
spacingScaleMultiplier
|
|
334
|
+
}
|
|
335
|
+
]}
|
|
249
336
|
>
|
|
250
|
-
<
|
|
251
|
-
style={[
|
|
252
|
-
IOListItemStyles.listItemInner,
|
|
253
|
-
{
|
|
254
|
-
columnGap:
|
|
255
|
-
IOListItemVisualParams.iconMargin *
|
|
256
|
-
dynamicFontScale *
|
|
257
|
-
spacingScaleMultiplier
|
|
258
|
-
}
|
|
259
|
-
]}
|
|
260
|
-
>
|
|
261
|
-
<ListItemInfoContent {...contentProps} />
|
|
262
|
-
</View>
|
|
337
|
+
<ListItemInfoContent {...contentProps} />
|
|
263
338
|
</View>
|
|
264
|
-
|
|
265
|
-
|
|
339
|
+
</View>
|
|
340
|
+
);
|
|
266
341
|
};
|
|
@@ -190,7 +190,8 @@ exports[`Test List Item Components - Experimental Enabled ListItemInfo Snapshot
|
|
|
190
190
|
}
|
|
191
191
|
>
|
|
192
192
|
<View
|
|
193
|
-
accessible={
|
|
193
|
+
accessible={false}
|
|
194
|
+
importantForAccessibility="no-hide-descendants"
|
|
194
195
|
style={
|
|
195
196
|
{
|
|
196
197
|
"flexDirection": "column",
|
|
@@ -1379,7 +1380,7 @@ exports[`Test List Item Components - Experimental Enabled ListItemRadioWithAmou
|
|
|
1379
1380
|
<RNSVGPath
|
|
1380
1381
|
d="m7 12 4 4 7-7"
|
|
1381
1382
|
fill={null}
|
|
1382
|
-
|
|
1383
|
+
onSvgLayout={[Function]}
|
|
1383
1384
|
propList={
|
|
1384
1385
|
[
|
|
1385
1386
|
"fill",
|
|
@@ -2308,7 +2309,8 @@ exports[`Test List Item Components ListItemInfo Snapshot 1`] = `
|
|
|
2308
2309
|
}
|
|
2309
2310
|
>
|
|
2310
2311
|
<View
|
|
2311
|
-
accessible={
|
|
2312
|
+
accessible={false}
|
|
2313
|
+
importantForAccessibility="no-hide-descendants"
|
|
2312
2314
|
style={
|
|
2313
2315
|
{
|
|
2314
2316
|
"flexDirection": "column",
|
|
@@ -3497,7 +3499,7 @@ exports[`Test List Item Components ListItemRadioWithAmount Snapshot 1`] = `
|
|
|
3497
3499
|
<RNSVGPath
|
|
3498
3500
|
d="m7 12 4 4 7-7"
|
|
3499
3501
|
fill={null}
|
|
3500
|
-
|
|
3502
|
+
onSvgLayout={[Function]}
|
|
3501
3503
|
propList={
|
|
3502
3504
|
[
|
|
3503
3505
|
"fill",
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
import { ReactElement,
|
|
1
|
+
import { ReactElement, useId } from "react";
|
|
2
2
|
import { ColorValue, View } from "react-native";
|
|
3
|
-
import Animated
|
|
4
|
-
Easing,
|
|
5
|
-
useAnimatedStyle,
|
|
6
|
-
useSharedValue,
|
|
7
|
-
withRepeat,
|
|
8
|
-
withTiming
|
|
9
|
-
} from "react-native-reanimated";
|
|
3
|
+
import Animated from "react-native-reanimated";
|
|
10
4
|
import Svg, { Defs, G, LinearGradient, Path, Stop } from "react-native-svg";
|
|
11
5
|
import { useIOTheme } from "../../context";
|
|
12
6
|
import { IOColors } from "../../core/IOColors";
|
|
@@ -21,15 +15,18 @@ export type LoadingSpinner = WithTestID<{
|
|
|
21
15
|
}>;
|
|
22
16
|
|
|
23
17
|
/**
|
|
24
|
-
* Size scale
|
|
25
|
-
* It will be removed in the future.
|
|
18
|
+
* Size scale
|
|
26
19
|
*/
|
|
27
|
-
export type IOLoadingSpinnerSizeScale = 24 | 48
|
|
20
|
+
export type IOLoadingSpinnerSizeScale = 24 | 48;
|
|
28
21
|
|
|
29
|
-
const
|
|
22
|
+
const spinKeyframes = {
|
|
23
|
+
from: { transform: [{ rotateZ: "0deg" }] },
|
|
24
|
+
to: { transform: [{ rotateZ: "360deg" }] }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const strokeMap: Record<IOLoadingSpinnerSizeScale, number> = {
|
|
30
28
|
24: 3,
|
|
31
|
-
48: 5
|
|
32
|
-
76: 7
|
|
29
|
+
48: 5
|
|
33
30
|
};
|
|
34
31
|
|
|
35
32
|
export const LoadingSpinner = ({
|
|
@@ -41,23 +38,13 @@ export const LoadingSpinner = ({
|
|
|
41
38
|
testID = "LoadingSpinnerTestID"
|
|
42
39
|
}: LoadingSpinner): ReactElement => {
|
|
43
40
|
const theme = useIOTheme();
|
|
44
|
-
const
|
|
45
|
-
const stroke
|
|
41
|
+
const id = useId();
|
|
42
|
+
const stroke = strokeMap[size];
|
|
46
43
|
|
|
47
44
|
const color = customColor ?? IOColors[theme["interactiveElem-default"]];
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
rotation.value = withRepeat(
|
|
52
|
-
withTiming(360, { duration: durationMs, easing: Easing.linear }),
|
|
53
|
-
-1,
|
|
54
|
-
false
|
|
55
|
-
);
|
|
56
|
-
}, [durationMs, rotation]);
|
|
57
|
-
|
|
58
|
-
const animatedStyle = useAnimatedStyle(() => ({
|
|
59
|
-
transform: [{ rotateZ: `${rotation.value}deg` }]
|
|
60
|
-
}));
|
|
46
|
+
const secondHalfId = `${id}-secondHalf`;
|
|
47
|
+
const firstHalfId = `${id}-firstHalf`;
|
|
61
48
|
|
|
62
49
|
return (
|
|
63
50
|
<View
|
|
@@ -70,7 +57,13 @@ export const LoadingSpinner = ({
|
|
|
70
57
|
>
|
|
71
58
|
<Animated.View
|
|
72
59
|
testID={"LoadingSpinnerAnimatedTestID"}
|
|
73
|
-
style={
|
|
60
|
+
style={{
|
|
61
|
+
animationName: spinKeyframes,
|
|
62
|
+
animationDuration: durationMs,
|
|
63
|
+
animationIterationCount: "infinite",
|
|
64
|
+
animationTimingFunction: "linear",
|
|
65
|
+
transformOrigin: "center"
|
|
66
|
+
}}
|
|
74
67
|
>
|
|
75
68
|
{/* Thanks to Ben Ilegbodu for the article on how to
|
|
76
69
|
create a a SVG gradient loading spinner. Below is
|
|
@@ -83,11 +76,11 @@ export const LoadingSpinner = ({
|
|
|
83
76
|
fill="none"
|
|
84
77
|
>
|
|
85
78
|
<Defs>
|
|
86
|
-
<LinearGradient id=
|
|
79
|
+
<LinearGradient id={secondHalfId}>
|
|
87
80
|
<Stop offset="0%" stopOpacity="0" stopColor={color} />
|
|
88
81
|
<Stop offset="100%" stopOpacity="1" stopColor={color} />
|
|
89
82
|
</LinearGradient>
|
|
90
|
-
<LinearGradient id=
|
|
83
|
+
<LinearGradient id={firstHalfId}>
|
|
91
84
|
<Stop offset="0%" stopOpacity="1" stopColor={color} />
|
|
92
85
|
<Stop offset="100%" stopOpacity="1" stopColor={color} />
|
|
93
86
|
</LinearGradient>
|
|
@@ -95,13 +88,13 @@ export const LoadingSpinner = ({
|
|
|
95
88
|
|
|
96
89
|
<G strokeWidth={stroke}>
|
|
97
90
|
<Path
|
|
98
|
-
stroke=
|
|
91
|
+
stroke={`url(#${secondHalfId})`}
|
|
99
92
|
d={`M ${stroke / 2} ${size / 2} A ${size / 2 - stroke / 2} ${
|
|
100
93
|
size / 2 - stroke / 2
|
|
101
94
|
} 0 0 1 ${size - stroke / 2} ${size / 2}`}
|
|
102
95
|
/>
|
|
103
96
|
<Path
|
|
104
|
-
stroke=
|
|
97
|
+
stroke={`url(#${firstHalfId})`}
|
|
105
98
|
d={`M ${size - stroke / 2} ${size / 2} A ${
|
|
106
99
|
size / 2 - stroke / 2
|
|
107
100
|
} ${size / 2 - stroke / 2} 0 0 1 ${stroke / 2} ${size / 2}`}
|