@ds-mo/ui 0.2.4 → 0.3.0
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/components/Accordion/Accordion.d.ts +29 -0
- package/dist/components/Accordion/Accordion.d.ts.map +1 -0
- package/dist/components/Accordion/index.d.ts +3 -0
- package/dist/components/Accordion/index.d.ts.map +1 -0
- package/dist/components/Badge/Badge.d.ts +18 -0
- package/dist/components/Badge/Badge.d.ts.map +1 -0
- package/dist/components/Badge/index.d.ts +3 -0
- package/dist/components/Badge/index.d.ts.map +1 -0
- package/dist/components/Banner/Banner.d.ts +16 -0
- package/dist/components/Banner/Banner.d.ts.map +1 -0
- package/dist/components/Banner/index.d.ts +3 -0
- package/dist/components/Banner/index.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts +15 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
- package/dist/components/Breadcrumb/index.d.ts +3 -0
- package/dist/components/Breadcrumb/index.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts +85 -0
- package/dist/components/Button/Button.d.ts.map +1 -0
- package/dist/components/Button/index.d.ts +3 -0
- package/dist/components/Button/index.d.ts.map +1 -0
- package/dist/components/ButtonGroup/ButtonGroup.d.ts +26 -0
- package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
- package/dist/components/ButtonGroup/index.d.ts +3 -0
- package/dist/components/ButtonGroup/index.d.ts.map +1 -0
- package/dist/components/Card/Card.d.ts +20 -0
- package/dist/components/Card/Card.d.ts.map +1 -0
- package/dist/components/Card/index.d.ts +3 -0
- package/dist/components/Card/index.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.d.ts +18 -0
- package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/components/Checkbox/index.d.ts +3 -0
- package/dist/components/Checkbox/index.d.ts.map +1 -0
- package/dist/components/Divider/Divider.d.ts +9 -0
- package/dist/components/Divider/Divider.d.ts.map +1 -0
- package/dist/components/Divider/index.d.ts +3 -0
- package/dist/components/Divider/index.d.ts.map +1 -0
- package/dist/components/EmptyState/EmptyState.d.ts +9 -0
- package/dist/components/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/components/EmptyState/index.d.ts +3 -0
- package/dist/components/EmptyState/index.d.ts.map +1 -0
- package/dist/components/ErrorBoundary/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary/index.d.ts +3 -0
- package/dist/components/ErrorBoundary/index.d.ts.map +1 -0
- package/dist/components/Fade/Fade.d.ts +11 -0
- package/dist/components/Fade/Fade.d.ts.map +1 -0
- package/dist/components/Fade/index.d.ts +3 -0
- package/dist/components/Fade/index.d.ts.map +1 -0
- package/dist/components/Field/Field.d.ts +9 -0
- package/dist/components/Field/Field.d.ts.map +1 -0
- package/dist/components/Field/index.d.ts +3 -0
- package/dist/components/Field/index.d.ts.map +1 -0
- package/dist/components/Header/Header.d.ts +12 -0
- package/dist/components/Header/Header.d.ts.map +1 -0
- package/dist/components/Header/index.d.ts +3 -0
- package/dist/components/Header/index.d.ts.map +1 -0
- package/dist/components/Input/Input.d.ts +27 -0
- package/dist/components/Input/Input.d.ts.map +1 -0
- package/dist/components/Input/index.d.ts +3 -0
- package/dist/components/Input/index.d.ts.map +1 -0
- package/dist/components/LabelWrap/LabelWrap.d.ts +27 -0
- package/dist/components/LabelWrap/LabelWrap.d.ts.map +1 -0
- package/dist/components/LabelWrap/index.d.ts +2 -0
- package/dist/components/LabelWrap/index.d.ts.map +1 -0
- package/dist/components/Loader/Loader.d.ts +33 -0
- package/dist/components/Loader/Loader.d.ts.map +1 -0
- package/dist/components/Loader/index.d.ts +3 -0
- package/dist/components/Loader/index.d.ts.map +1 -0
- package/dist/components/Menu/DestructiveMenuItem.d.ts +12 -0
- package/dist/components/Menu/DestructiveMenuItem.d.ts.map +1 -0
- package/dist/components/Menu/Menu.d.ts +55 -0
- package/dist/components/Menu/Menu.d.ts.map +1 -0
- package/dist/components/Menu/MenuItem.d.ts +33 -0
- package/dist/components/Menu/MenuItem.d.ts.map +1 -0
- package/dist/components/Menu/index.d.ts +7 -0
- package/dist/components/Menu/index.d.ts.map +1 -0
- package/dist/components/Modal/Modal.d.ts +15 -0
- package/dist/components/Modal/Modal.d.ts.map +1 -0
- package/dist/components/Modal/index.d.ts +3 -0
- package/dist/components/Modal/index.d.ts.map +1 -0
- package/dist/components/Pagination/Pagination.d.ts +16 -0
- package/dist/components/Pagination/Pagination.d.ts.map +1 -0
- package/dist/components/Pagination/index.d.ts +3 -0
- package/dist/components/Pagination/index.d.ts.map +1 -0
- package/dist/components/Radio/Radio.d.ts +35 -0
- package/dist/components/Radio/Radio.d.ts.map +1 -0
- package/dist/components/Radio/index.d.ts +3 -0
- package/dist/components/Radio/index.d.ts.map +1 -0
- package/dist/components/Scrollbar/Scrollbar.d.ts +13 -0
- package/dist/components/Scrollbar/Scrollbar.d.ts.map +1 -0
- package/dist/components/Scrollbar/index.d.ts +3 -0
- package/dist/components/Scrollbar/index.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +22 -0
- package/dist/components/Select/Select.d.ts.map +1 -0
- package/dist/components/Select/index.d.ts +3 -0
- package/dist/components/Select/index.d.ts.map +1 -0
- package/dist/components/Sidebar/Sidebar.d.ts +15 -0
- package/dist/components/Sidebar/Sidebar.d.ts.map +1 -0
- package/dist/components/Sidebar/SidebarItem.d.ts +14 -0
- package/dist/components/Sidebar/SidebarItem.d.ts.map +1 -0
- package/dist/components/Sidebar/SidebarSection.d.ts +10 -0
- package/dist/components/Sidebar/SidebarSection.d.ts.map +1 -0
- package/dist/components/Sidebar/index.d.ts +7 -0
- package/dist/components/Sidebar/index.d.ts.map +1 -0
- package/dist/components/Skeleton/Skeleton.d.ts +17 -0
- package/dist/components/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/components/Skeleton/index.d.ts +3 -0
- package/dist/components/Skeleton/index.d.ts.map +1 -0
- package/dist/components/Slider/Slider.d.ts +16 -0
- package/dist/components/Slider/Slider.d.ts.map +1 -0
- package/dist/components/Slider/index.d.ts +3 -0
- package/dist/components/Slider/index.d.ts.map +1 -0
- package/dist/components/Surface/Surface.d.ts +59 -0
- package/dist/components/Surface/Surface.d.ts.map +1 -0
- package/dist/components/Surface/index.d.ts +3 -0
- package/dist/components/Surface/index.d.ts.map +1 -0
- package/dist/components/Tab/Tab.d.ts +21 -0
- package/dist/components/Tab/Tab.d.ts.map +1 -0
- package/dist/components/Tab/index.d.ts +3 -0
- package/dist/components/Tab/index.d.ts.map +1 -0
- package/dist/components/TabGroup/TabGroup.d.ts +26 -0
- package/dist/components/TabGroup/TabGroup.d.ts.map +1 -0
- package/dist/components/TabGroup/index.d.ts +3 -0
- package/dist/components/TabGroup/index.d.ts.map +1 -0
- package/dist/components/Table/Table.d.ts +46 -0
- package/dist/components/Table/Table.d.ts.map +1 -0
- package/dist/components/Table/index.d.ts +3 -0
- package/dist/components/Table/index.d.ts.map +1 -0
- package/dist/components/Tag/Tag.d.ts +38 -0
- package/dist/components/Tag/Tag.d.ts.map +1 -0
- package/dist/components/Tag/index.d.ts +3 -0
- package/dist/components/Tag/index.d.ts.map +1 -0
- package/dist/components/Text/Text.d.ts +41 -0
- package/dist/components/Text/Text.d.ts.map +1 -0
- package/dist/components/Text/index.d.ts +3 -0
- package/dist/components/Text/index.d.ts.map +1 -0
- package/dist/components/Toast/Toast.d.ts +33 -0
- package/dist/components/Toast/Toast.d.ts.map +1 -0
- package/dist/components/Toast/index.d.ts +3 -0
- package/dist/components/Toast/index.d.ts.map +1 -0
- package/dist/components/Toggle/Toggle.d.ts +17 -0
- package/dist/components/Toggle/Toggle.d.ts.map +1 -0
- package/dist/components/Toggle/index.d.ts +3 -0
- package/dist/components/Toggle/index.d.ts.map +1 -0
- package/dist/components/ToggleButton/ToggleButton.d.ts +35 -0
- package/dist/components/ToggleButton/ToggleButton.d.ts.map +1 -0
- package/dist/components/ToggleButton/index.d.ts +3 -0
- package/dist/components/ToggleButton/index.d.ts.map +1 -0
- package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts +24 -0
- package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts.map +1 -0
- package/dist/components/ToggleButtonGroup/index.d.ts +3 -0
- package/dist/components/ToggleButtonGroup/index.d.ts.map +1 -0
- package/dist/components/Tooltip/Tooltip.d.ts +17 -0
- package/dist/components/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/components/Tooltip/index.d.ts +3 -0
- package/dist/components/Tooltip/index.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +82 -969
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1641 -1165
- package/dist/index.js.map +1 -1
- package/dist/r/badge.json +1 -1
- package/dist/r/banner.json +2 -2
- package/dist/r/loader.json +2 -2
- package/dist/r/menu.json +4 -4
- package/dist/r/select.json +1 -1
- package/dist/r/tab-group.json +1 -1
- package/dist/r/tab.json +1 -1
- package/dist/r/toggle.json +1 -1
- package/dist/types/icons.d.ts +14 -0
- package/dist/types/icons.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/utils/cn.d.ts +5 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/dist/utils/css-tokens.d.ts +11 -0
- package/dist/utils/css-tokens.d.ts.map +1 -0
- package/package.json +17 -12
- package/src/components/Accordion/Accordion.tsx +10 -1
- package/src/components/Badge/Badge.stories.tsx +32 -0
- package/src/components/Badge/Badge.tsx +19 -2
- package/src/components/Banner/Banner.module.css +38 -0
- package/src/components/Banner/Banner.tsx +34 -4
- package/src/components/Button/Button.stories.tsx +1 -1
- package/src/components/Button/Button.tsx +42 -11
- package/src/components/Checkbox/Checkbox.tsx +6 -3
- package/src/components/Field/Field.tsx +20 -7
- package/src/components/Input/Input.module.css +21 -0
- package/src/components/Input/Input.tsx +62 -21
- package/src/components/Loader/Loader.module.css +17 -0
- package/src/components/Loader/Loader.stories.tsx +9 -0
- package/src/components/Loader/Loader.tsx +42 -20
- package/src/components/Menu/DestructiveMenuItem.tsx +59 -6
- package/src/components/Menu/Menu.tsx +160 -11
- package/src/components/Menu/MenuItem.module.css +2 -0
- package/src/components/Menu/MenuItem.tsx +48 -5
- package/src/components/Modal/Modal.tsx +81 -4
- package/src/components/Radio/Radio.tsx +117 -29
- package/src/components/Scrollbar/Scrollbar.module.css +5 -0
- package/src/components/Scrollbar/Scrollbar.tsx +18 -4
- package/src/components/Select/Select.tsx +139 -9
- package/src/components/Sidebar/Sidebar.module.css +7 -0
- package/src/components/Sidebar/Sidebar.tsx +102 -4
- package/src/components/Slider/Slider.module.css +5 -1
- package/src/components/Slider/Slider.stories.tsx +25 -0
- package/src/components/Slider/Slider.tsx +4 -4
- package/src/components/Tab/Tab.tsx +32 -2
- package/src/components/TabGroup/TabGroup.stories.tsx +46 -15
- package/src/components/TabGroup/TabGroup.tsx +77 -10
- package/src/components/Table/Table.module.css +10 -0
- package/src/components/Table/Table.tsx +58 -29
- package/src/components/Tag/Tag.tsx +5 -2
- package/src/components/Toast/Toast.tsx +44 -8
- package/src/components/Toggle/Toggle.tsx +23 -22
- package/src/components/Tooltip/Tooltip.tsx +42 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef } from 'react';
|
|
1
|
+
import React, { forwardRef, useRef } from 'react';
|
|
2
2
|
import { cn } from '@/utils/cn';
|
|
3
3
|
import { Text } from '@/components/Text';
|
|
4
4
|
import styles from './Radio.module.css';
|
|
@@ -16,8 +16,10 @@ export interface RadioGroupProps {
|
|
|
16
16
|
onChange: (value: string) => void;
|
|
17
17
|
/** Radio options to render. */
|
|
18
18
|
options: RadioOption[];
|
|
19
|
-
/**
|
|
19
|
+
/** Accessible name for the group. One of `aria-label` or `aria-labelledby` is required. */
|
|
20
20
|
'aria-label'?: string;
|
|
21
|
+
/** ID of an element labeling the group. One of `aria-label` or `aria-labelledby` is required. */
|
|
22
|
+
'aria-labelledby'?: string;
|
|
21
23
|
/** Layout direction. */
|
|
22
24
|
direction?: 'vertical' | 'horizontal';
|
|
23
25
|
/** Disables the entire group. */
|
|
@@ -32,33 +34,111 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
|
|
|
32
34
|
onChange,
|
|
33
35
|
options,
|
|
34
36
|
'aria-label': ariaLabel,
|
|
37
|
+
'aria-labelledby': ariaLabelledBy,
|
|
35
38
|
direction = 'vertical',
|
|
36
39
|
inactive = false,
|
|
37
40
|
className,
|
|
38
41
|
},
|
|
39
42
|
ref
|
|
40
|
-
) =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
43
|
+
) => {
|
|
44
|
+
if (!ariaLabel && !ariaLabelledBy) {
|
|
45
|
+
const env = (globalThis as { process?: { env?: { NODE_ENV?: string } } })
|
|
46
|
+
.process?.env?.NODE_ENV;
|
|
47
|
+
if (env !== 'production') {
|
|
48
|
+
console.warn(
|
|
49
|
+
'RadioGroup: an accessible name is required. Pass either `aria-label` or `aria-labelledby`.'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const itemRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
55
|
+
|
|
56
|
+
const isActive = (i: number) =>
|
|
57
|
+
i >= 0 && i < options.length && !inactive && !options[i].inactive;
|
|
58
|
+
|
|
59
|
+
const activeIndices = options.reduce<number[]>((acc, _, i) => {
|
|
60
|
+
if (isActive(i)) acc.push(i);
|
|
61
|
+
return acc;
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const selectedIdx = options.findIndex(o => o.value === value);
|
|
65
|
+
const focusableIdx = isActive(selectedIdx)
|
|
66
|
+
? selectedIdx
|
|
67
|
+
: (activeIndices[0] ?? -1);
|
|
68
|
+
|
|
69
|
+
const focusAndSelect = (i: number) => {
|
|
70
|
+
itemRefs.current[i]?.focus();
|
|
71
|
+
if (options[i].value !== value) onChange(options[i].value);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const moveFocus = (from: number, delta: 1 | -1) => {
|
|
75
|
+
if (activeIndices.length === 0) return;
|
|
76
|
+
const pos = activeIndices.indexOf(from);
|
|
77
|
+
// If focus somehow lands on an inactive radio, anchor at the boundary so
|
|
78
|
+
// the first arrow keystroke moves to the nearest active option.
|
|
79
|
+
const startPos = pos === -1 ? (delta === 1 ? -1 : 0) : pos;
|
|
80
|
+
const nextPos =
|
|
81
|
+
(startPos + delta + activeIndices.length) % activeIndices.length;
|
|
82
|
+
focusAndSelect(activeIndices[nextPos]);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
86
|
+
const target = e.target as HTMLElement;
|
|
87
|
+
const idx = itemRefs.current.findIndex(el => el === target);
|
|
88
|
+
if (idx === -1) return;
|
|
89
|
+
|
|
90
|
+
switch (e.key) {
|
|
91
|
+
case 'ArrowDown':
|
|
92
|
+
case 'ArrowRight':
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
moveFocus(idx, 1);
|
|
95
|
+
break;
|
|
96
|
+
case 'ArrowUp':
|
|
97
|
+
case 'ArrowLeft':
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
moveFocus(idx, -1);
|
|
100
|
+
break;
|
|
101
|
+
case 'Home':
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
if (activeIndices.length) focusAndSelect(activeIndices[0]);
|
|
104
|
+
break;
|
|
105
|
+
case 'End':
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
if (activeIndices.length)
|
|
108
|
+
focusAndSelect(activeIndices[activeIndices.length - 1]);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
ref={ref}
|
|
116
|
+
role="radiogroup"
|
|
117
|
+
aria-label={ariaLabel}
|
|
118
|
+
aria-labelledby={ariaLabelledBy}
|
|
119
|
+
onKeyDown={handleKeyDown}
|
|
120
|
+
className={cn(
|
|
121
|
+
styles.group,
|
|
122
|
+
direction === 'horizontal' && styles.horizontal,
|
|
123
|
+
className
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{options.map((opt, i) => (
|
|
127
|
+
<RadioItem
|
|
128
|
+
key={opt.value}
|
|
129
|
+
ref={el => {
|
|
130
|
+
itemRefs.current[i] = el;
|
|
131
|
+
}}
|
|
132
|
+
label={opt.label}
|
|
133
|
+
checked={value === opt.value}
|
|
134
|
+
inactive={inactive || opt.inactive}
|
|
135
|
+
tabIndex={i === focusableIdx ? 0 : -1}
|
|
136
|
+
onChange={() => onChange(opt.value)}
|
|
137
|
+
/>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
62
142
|
);
|
|
63
143
|
|
|
64
144
|
RadioGroup.displayName = 'RadioGroup';
|
|
@@ -70,27 +150,35 @@ export interface RadioItemProps {
|
|
|
70
150
|
checked?: boolean;
|
|
71
151
|
onChange?: () => void;
|
|
72
152
|
inactive?: boolean;
|
|
153
|
+
/** Roving tabindex value. RadioGroup manages this; defaults to -1 when inactive, else 0. */
|
|
154
|
+
tabIndex?: number;
|
|
73
155
|
className?: string;
|
|
74
156
|
}
|
|
75
157
|
|
|
76
158
|
export const RadioItem = forwardRef<HTMLDivElement, RadioItemProps>(
|
|
77
|
-
(
|
|
78
|
-
|
|
159
|
+
(
|
|
160
|
+
{ label, checked = false, onChange, inactive = false, tabIndex, className },
|
|
161
|
+
ref
|
|
162
|
+
) => {
|
|
163
|
+
const handleActivate = () => {
|
|
79
164
|
if (!inactive && !checked) onChange?.();
|
|
80
165
|
};
|
|
81
166
|
|
|
167
|
+
const resolvedTabIndex = tabIndex ?? (inactive ? -1 : 0);
|
|
168
|
+
|
|
82
169
|
return (
|
|
83
170
|
<div
|
|
84
171
|
ref={ref}
|
|
85
172
|
role="radio"
|
|
86
173
|
aria-checked={checked}
|
|
87
|
-
|
|
174
|
+
aria-disabled={inactive || undefined}
|
|
175
|
+
tabIndex={resolvedTabIndex}
|
|
88
176
|
className={cn(styles.radio, inactive && styles.inactive, className)}
|
|
89
|
-
onClick={
|
|
177
|
+
onClick={handleActivate}
|
|
90
178
|
onKeyDown={e => {
|
|
91
179
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
92
180
|
e.preventDefault();
|
|
93
|
-
|
|
181
|
+
handleActivate();
|
|
94
182
|
}
|
|
95
183
|
}}
|
|
96
184
|
>
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
|
|
18
18
|
.content::-webkit-scrollbar { display: none; }
|
|
19
19
|
|
|
20
|
+
.content:focus-visible {
|
|
21
|
+
outline: var(--dimension-stroke-width-025) solid var(--color-interaction-focus);
|
|
22
|
+
outline-offset: calc(var(--dimension-space-025) * -1);
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
.track {
|
|
21
26
|
position: absolute;
|
|
22
27
|
top: 0; right: 0; bottom: 0;
|
|
@@ -158,7 +158,15 @@ export const Scrollbar: React.FC<ScrollbarProps> = ({
|
|
|
158
158
|
const showThumb = thumbHeight > 0;
|
|
159
159
|
const showThumbH = thumbWidth > 0;
|
|
160
160
|
const showTrack = showTrackOnHover && (isPointerInside || isDragging || isDraggingH);
|
|
161
|
-
|
|
161
|
+
const isScrollable = showThumb || showThumbH;
|
|
162
|
+
|
|
163
|
+
// A11y model: the custom thumbs are a visual reskin of the native scrollbar,
|
|
164
|
+
// not a separate widget. The `.content` div retains native `overflow: auto`,
|
|
165
|
+
// so when it is focusable the browser handles arrow keys, Page Up/Down,
|
|
166
|
+
// Home/End, and Space for free — implementing the full `role="scrollbar"`
|
|
167
|
+
// ARIA pattern would just re-create that behavior. We therefore expose the
|
|
168
|
+
// scroll container itself as the keyboard target (tabIndex=0 only when
|
|
169
|
+
// content overflows) and hide the decorative thumbs from assistive tech.
|
|
162
170
|
return (
|
|
163
171
|
<div
|
|
164
172
|
ref={containerRef}
|
|
@@ -168,15 +176,21 @@ export const Scrollbar: React.FC<ScrollbarProps> = ({
|
|
|
168
176
|
data-dragging={isDragging || isDraggingH}
|
|
169
177
|
data-show-track={showTrack ? 'true' : 'false'}
|
|
170
178
|
>
|
|
171
|
-
<div
|
|
179
|
+
<div
|
|
180
|
+
ref={contentRef}
|
|
181
|
+
className={styles.content}
|
|
182
|
+
tabIndex={isScrollable ? 0 : undefined}
|
|
183
|
+
>
|
|
184
|
+
{children}
|
|
185
|
+
</div>
|
|
172
186
|
{fadeOverlay}
|
|
173
187
|
{showThumb && (
|
|
174
|
-
<div className={styles.track}>
|
|
188
|
+
<div className={styles.track} aria-hidden="true">
|
|
175
189
|
<div ref={thumbRef} className={styles.thumb} style={{ height: `${thumbHeight}px`, top: `${thumbTop}px` }} onMouseDown={handleThumbMouseDown} />
|
|
176
190
|
</div>
|
|
177
191
|
)}
|
|
178
192
|
{showThumbH && (
|
|
179
|
-
<div className={styles.trackHorizontal}>
|
|
193
|
+
<div className={styles.trackHorizontal} aria-hidden="true">
|
|
180
194
|
<div ref={thumbHRef} className={styles.thumbHorizontal} style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }} onMouseDown={handleThumbHMouseDown} />
|
|
181
195
|
</div>
|
|
182
196
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef, useState, useRef } from 'react';
|
|
1
|
+
import React, { forwardRef, useId, useState, useRef, useEffect, useCallback } from 'react';
|
|
2
2
|
import { Surface } from '@/components/Surface';
|
|
3
3
|
import { Text } from '@/components/Text';
|
|
4
4
|
import { Menu } from '@/components/Menu';
|
|
@@ -20,33 +20,152 @@ export interface SelectProps {
|
|
|
20
20
|
inactive?: boolean;
|
|
21
21
|
id?: string;
|
|
22
22
|
chevronIcon?: IconComponent;
|
|
23
|
+
/** Accessible name. Required when no associated `<label htmlFor>` element exists. */
|
|
24
|
+
'aria-label'?: string;
|
|
25
|
+
/** Accessible name reference. Use when a visible label already exists outside the trigger. */
|
|
26
|
+
'aria-labelledby'?: string;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
26
|
-
(
|
|
30
|
+
(
|
|
31
|
+
{
|
|
32
|
+
value,
|
|
33
|
+
onChange,
|
|
34
|
+
options,
|
|
35
|
+
placeholder = 'Select option',
|
|
36
|
+
className,
|
|
37
|
+
inactive = false,
|
|
38
|
+
id,
|
|
39
|
+
chevronIcon: ChevronIcon,
|
|
40
|
+
'aria-label': ariaLabel,
|
|
41
|
+
'aria-labelledby': ariaLabelledBy,
|
|
42
|
+
},
|
|
43
|
+
ref
|
|
44
|
+
) => {
|
|
27
45
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
46
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
28
47
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
29
48
|
|
|
30
|
-
const
|
|
49
|
+
const reactId = useId();
|
|
50
|
+
const triggerId = id ?? `select-${reactId}`;
|
|
51
|
+
const listboxId = `${triggerId}-listbox`;
|
|
52
|
+
const getOptionId = useCallback((index: number) => `${triggerId}-option-${index}`, [triggerId]);
|
|
53
|
+
|
|
54
|
+
const selectedIndex = options.findIndex(opt => opt.value === value);
|
|
55
|
+
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
|
|
31
56
|
const selectedLabel = selectedOption?.label || placeholder;
|
|
32
57
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
const commitSelection = useCallback(
|
|
59
|
+
(index: number) => {
|
|
60
|
+
const option = options[index];
|
|
61
|
+
if (!option) return;
|
|
36
62
|
onChange(option.value);
|
|
37
63
|
setIsMenuOpen(false);
|
|
38
64
|
},
|
|
65
|
+
[options, onChange]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const openWith = useCallback(
|
|
69
|
+
(initialIndex: number) => {
|
|
70
|
+
if (inactive || options.length === 0) return;
|
|
71
|
+
const clamped = Math.max(0, Math.min(options.length - 1, initialIndex));
|
|
72
|
+
setActiveIndex(clamped);
|
|
73
|
+
setIsMenuOpen(true);
|
|
74
|
+
},
|
|
75
|
+
[inactive, options.length]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (isMenuOpen) {
|
|
80
|
+
setActiveIndex(prev => {
|
|
81
|
+
if (prev >= 0 && prev < options.length) return prev;
|
|
82
|
+
return selectedIndex >= 0 ? selectedIndex : 0;
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
setActiveIndex(-1);
|
|
86
|
+
}
|
|
87
|
+
}, [isMenuOpen, options.length, selectedIndex]);
|
|
88
|
+
|
|
89
|
+
const menuItems: MenuItemData[] = options.map((option, index) => ({
|
|
90
|
+
label: option.label,
|
|
91
|
+
onClick: () => commitSelection(index),
|
|
39
92
|
isSelected: option.value === value,
|
|
40
93
|
}));
|
|
41
94
|
|
|
42
95
|
const handleToggle = () => {
|
|
43
|
-
if (
|
|
96
|
+
if (inactive) return;
|
|
97
|
+
if (isMenuOpen) {
|
|
98
|
+
setIsMenuOpen(false);
|
|
99
|
+
} else {
|
|
100
|
+
openWith(selectedIndex >= 0 ? selectedIndex : 0);
|
|
101
|
+
}
|
|
44
102
|
};
|
|
45
103
|
|
|
104
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
105
|
+
if (inactive || options.length === 0) return;
|
|
106
|
+
const last = options.length - 1;
|
|
107
|
+
|
|
108
|
+
if (!isMenuOpen) {
|
|
109
|
+
switch (event.key) {
|
|
110
|
+
case 'ArrowDown':
|
|
111
|
+
case 'ArrowUp':
|
|
112
|
+
case 'Enter':
|
|
113
|
+
case ' ':
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
openWith(selectedIndex >= 0 ? selectedIndex : 0);
|
|
116
|
+
return;
|
|
117
|
+
case 'Home':
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
openWith(0);
|
|
120
|
+
return;
|
|
121
|
+
case 'End':
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
openWith(last);
|
|
124
|
+
return;
|
|
125
|
+
default:
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
switch (event.key) {
|
|
131
|
+
case 'ArrowDown':
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
setActiveIndex(prev => Math.min(last, (prev < 0 ? -1 : prev) + 1));
|
|
134
|
+
break;
|
|
135
|
+
case 'ArrowUp':
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
setActiveIndex(prev => Math.max(0, (prev < 0 ? options.length : prev) - 1));
|
|
138
|
+
break;
|
|
139
|
+
case 'Home':
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
setActiveIndex(0);
|
|
142
|
+
break;
|
|
143
|
+
case 'End':
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
setActiveIndex(last);
|
|
146
|
+
break;
|
|
147
|
+
case 'Enter':
|
|
148
|
+
case ' ':
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
if (activeIndex >= 0) commitSelection(activeIndex);
|
|
151
|
+
break;
|
|
152
|
+
case 'Escape':
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
setIsMenuOpen(false);
|
|
155
|
+
break;
|
|
156
|
+
case 'Tab':
|
|
157
|
+
setIsMenuOpen(false);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const activeDescendantId =
|
|
163
|
+
isMenuOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined;
|
|
164
|
+
|
|
46
165
|
return (
|
|
47
166
|
<div ref={ref} className={`${styles.selectWrapper} ${className || ''}`}>
|
|
48
167
|
<Surface
|
|
49
|
-
id={
|
|
168
|
+
id={triggerId}
|
|
50
169
|
ref={buttonRef as React.RefObject<HTMLElement>}
|
|
51
170
|
as="button"
|
|
52
171
|
type="button"
|
|
@@ -56,12 +175,19 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
|
56
175
|
selected={isMenuOpen}
|
|
57
176
|
className={styles.selectButton}
|
|
58
177
|
onClick={handleToggle}
|
|
178
|
+
onKeyDown={handleKeyDown}
|
|
59
179
|
inactive={inactive}
|
|
180
|
+
role="combobox"
|
|
181
|
+
aria-haspopup="listbox"
|
|
182
|
+
aria-expanded={isMenuOpen}
|
|
183
|
+
aria-controls={isMenuOpen ? listboxId : undefined}
|
|
184
|
+
aria-activedescendant={activeDescendantId}
|
|
185
|
+
aria-label={ariaLabel}
|
|
186
|
+
aria-labelledby={ariaLabelledBy}
|
|
60
187
|
>
|
|
61
188
|
<Text
|
|
62
189
|
variant="text-body-medium"
|
|
63
190
|
as="span"
|
|
64
|
-
|
|
65
191
|
color={selectedOption ? 'primary' : 'secondary'}
|
|
66
192
|
>
|
|
67
193
|
{selectedLabel}
|
|
@@ -83,6 +209,10 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
|
83
209
|
alignOffset={-4}
|
|
84
210
|
matchAnchorWidth
|
|
85
211
|
matchAnchorWidthOffset={8}
|
|
212
|
+
role="listbox"
|
|
213
|
+
id={listboxId}
|
|
214
|
+
activeIndex={activeIndex}
|
|
215
|
+
getOptionId={getOptionId}
|
|
86
216
|
/>
|
|
87
217
|
</div>
|
|
88
218
|
);
|
|
@@ -66,6 +66,13 @@
|
|
|
66
66
|
opacity: 0.5;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
.resizeHandle:focus-visible {
|
|
70
|
+
outline: var(--dimension-stroke-width-025) solid var(--color-border-brand);
|
|
71
|
+
outline-offset: var(--dimension-stroke-width-015);
|
|
72
|
+
background-color: var(--color-border-brand);
|
|
73
|
+
opacity: 0.5;
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
/* Sections */
|
|
70
77
|
.section {
|
|
71
78
|
display: flex;
|
|
@@ -18,8 +18,19 @@ export interface SidebarProps {
|
|
|
18
18
|
|
|
19
19
|
const MINI_WIDTH = 56;
|
|
20
20
|
const DEFAULT_WIDTH = 240;
|
|
21
|
+
const MAX_WIDTH = 480;
|
|
21
22
|
const MIN_DRAG_WIDTH = 180;
|
|
22
23
|
const SNAP_THRESHOLD = 140;
|
|
24
|
+
const KEYBOARD_STEP = 16;
|
|
25
|
+
|
|
26
|
+
const FOCUSABLE_SELECTOR = [
|
|
27
|
+
'a[href]',
|
|
28
|
+
'button:not([disabled])',
|
|
29
|
+
'input:not([disabled])',
|
|
30
|
+
'select:not([disabled])',
|
|
31
|
+
'textarea:not([disabled])',
|
|
32
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
33
|
+
].join(',');
|
|
23
34
|
|
|
24
35
|
export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
|
|
25
36
|
({
|
|
@@ -35,6 +46,7 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
|
|
|
35
46
|
}, ref) => {
|
|
36
47
|
const internalRef = useRef<HTMLElement>(null);
|
|
37
48
|
const sidebarRef = (ref as React.RefObject<HTMLElement>) || internalRef;
|
|
49
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
38
50
|
const [isResizing, setIsResizing] = useState(false);
|
|
39
51
|
|
|
40
52
|
const isMini = width === 'mini';
|
|
@@ -44,7 +56,6 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
|
|
|
44
56
|
if (!resizable || !onWidthChange) return;
|
|
45
57
|
e.preventDefault();
|
|
46
58
|
setIsResizing(true);
|
|
47
|
-
const startX = e.clientX;
|
|
48
59
|
|
|
49
60
|
const handleMouseMove = (e: MouseEvent) => {
|
|
50
61
|
const newWidth = e.clientX;
|
|
@@ -71,6 +82,39 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
|
|
|
71
82
|
document.addEventListener('mouseup', handleMouseUp);
|
|
72
83
|
}, [resizable, onWidthChange]);
|
|
73
84
|
|
|
85
|
+
// Keyboard resize: arrows step, Home → mini, End → max.
|
|
86
|
+
// Stepping below MIN_DRAG_WIDTH snaps to 'mini'; stepping up from 'mini' snaps to 'default'.
|
|
87
|
+
const handleResizeKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
88
|
+
if (!resizable || !onWidthChange) return;
|
|
89
|
+
switch (e.key) {
|
|
90
|
+
case 'ArrowLeft': {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
if (width === 'mini') return;
|
|
93
|
+
const next = resolvedWidth - KEYBOARD_STEP;
|
|
94
|
+
onWidthChange(next < MIN_DRAG_WIDTH ? 'mini' : next);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'ArrowRight': {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
if (width === 'mini') {
|
|
100
|
+
onWidthChange('default');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const next = Math.min(resolvedWidth + KEYBOARD_STEP, MAX_WIDTH);
|
|
104
|
+
onWidthChange(next);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'Home':
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
onWidthChange('mini');
|
|
110
|
+
break;
|
|
111
|
+
case 'End':
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
onWidthChange(MAX_WIDTH);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}, [resizable, onWidthChange, width, resolvedWidth]);
|
|
117
|
+
|
|
74
118
|
// Close sidebar on Escape (mobile)
|
|
75
119
|
useEffect(() => {
|
|
76
120
|
if (!isMobile || isCollapsed) return;
|
|
@@ -81,13 +125,62 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
|
|
|
81
125
|
return () => document.removeEventListener('keydown', handleEscape);
|
|
82
126
|
}, [isMobile, isCollapsed, onToggle]);
|
|
83
127
|
|
|
128
|
+
// Mobile dialog: capture trigger, focus first element, trap Tab, restore focus on close.
|
|
129
|
+
const isMobileOpen = isMobile && !isCollapsed;
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!isMobileOpen) return;
|
|
132
|
+
triggerRef.current = (document.activeElement as HTMLElement) ?? null;
|
|
133
|
+
|
|
134
|
+
const container = sidebarRef.current;
|
|
135
|
+
if (!container) return;
|
|
136
|
+
|
|
137
|
+
const focusables = () =>
|
|
138
|
+
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
139
|
+
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const initial = focusables();
|
|
143
|
+
(initial[0] ?? container).focus();
|
|
144
|
+
|
|
145
|
+
const handleTab = (e: KeyboardEvent) => {
|
|
146
|
+
if (e.key !== 'Tab') return;
|
|
147
|
+
const items = focusables();
|
|
148
|
+
if (items.length === 0) {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
container.focus();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const first = items[0];
|
|
154
|
+
const last = items[items.length - 1];
|
|
155
|
+
const active = document.activeElement as HTMLElement | null;
|
|
156
|
+
if (e.shiftKey && (active === first || !container.contains(active))) {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
last.focus();
|
|
159
|
+
} else if (!e.shiftKey && (active === last || !container.contains(active))) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
first.focus();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
document.addEventListener('keydown', handleTab);
|
|
166
|
+
return () => {
|
|
167
|
+
document.removeEventListener('keydown', handleTab);
|
|
168
|
+
triggerRef.current?.focus?.();
|
|
169
|
+
triggerRef.current = null;
|
|
170
|
+
};
|
|
171
|
+
}, [isMobileOpen, sidebarRef]);
|
|
172
|
+
|
|
84
173
|
if (isCollapsed && !isMobile) return null;
|
|
85
174
|
|
|
175
|
+
const dialogProps = isMobileOpen
|
|
176
|
+
? { role: 'dialog' as const, 'aria-modal': true, tabIndex: -1 }
|
|
177
|
+
: { role: 'navigation' as const };
|
|
178
|
+
|
|
86
179
|
return (
|
|
87
180
|
<>
|
|
88
|
-
{/* Mobile backdrop */}
|
|
181
|
+
{/* Mobile backdrop — mouse-only convenience; Escape handler covers keyboard. */}
|
|
89
182
|
{isMobile && !isCollapsed && (
|
|
90
|
-
<div className={styles.backdrop} onClick={onToggle} aria-hidden />
|
|
183
|
+
<div className={styles.backdrop} onClick={onToggle} aria-hidden="true" />
|
|
91
184
|
)}
|
|
92
185
|
<aside
|
|
93
186
|
ref={sidebarRef}
|
|
@@ -100,8 +193,8 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
|
|
|
100
193
|
className
|
|
101
194
|
)}
|
|
102
195
|
style={{ width: isCollapsed ? 0 : resolvedWidth }}
|
|
103
|
-
role="navigation"
|
|
104
196
|
aria-label="Sidebar"
|
|
197
|
+
{...dialogProps}
|
|
105
198
|
>
|
|
106
199
|
<div className={styles.content}>
|
|
107
200
|
{children}
|
|
@@ -115,9 +208,14 @@ export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
|
|
|
115
208
|
<div
|
|
116
209
|
className={styles.resizeHandle}
|
|
117
210
|
onMouseDown={handleMouseDown}
|
|
211
|
+
onKeyDown={handleResizeKeyDown}
|
|
118
212
|
role="separator"
|
|
119
213
|
aria-orientation="vertical"
|
|
120
214
|
aria-label="Resize sidebar"
|
|
215
|
+
aria-valuenow={resolvedWidth}
|
|
216
|
+
aria-valuemin={MINI_WIDTH}
|
|
217
|
+
aria-valuemax={MAX_WIDTH}
|
|
218
|
+
tabIndex={0}
|
|
121
219
|
/>
|
|
122
220
|
)}
|
|
123
221
|
</aside>
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
box-shadow: var(--effect-elevation-depressed-sm); pointer-events: none; z-index: 1;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
.fillTrack {
|
|
16
|
+
.fillTrack {
|
|
17
|
+
position: absolute; top: var(--dimension-space-025); right: 0; bottom: var(--dimension-space-025); left: 0;
|
|
18
|
+
z-index: 1; pointer-events: none;
|
|
19
|
+
}
|
|
17
20
|
|
|
18
21
|
.fill {
|
|
19
22
|
position: absolute; left: var(--dimension-space-025); top: 0; bottom: 0;
|
|
@@ -22,6 +25,7 @@
|
|
|
22
25
|
box-shadow: var(--effect-elevation-elevated-sm);
|
|
23
26
|
border-radius: var(--dimension-radius-050);
|
|
24
27
|
transition: opacity var(--effect-motion-short-2);
|
|
28
|
+
pointer-events: none;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
.trackAtMin .fill { opacity: 0; }
|
|
@@ -110,6 +110,31 @@ export const CustomRange: Story = {
|
|
|
110
110
|
),
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
+
const SIGNAL_LABELS = ['Off', 'Low', 'Medium', 'High', 'Max'];
|
|
114
|
+
|
|
115
|
+
const SignalSlider = () => {
|
|
116
|
+
const [value, setValue] = useState(2);
|
|
117
|
+
return (
|
|
118
|
+
<Slider
|
|
119
|
+
label="Signal strength"
|
|
120
|
+
min={0}
|
|
121
|
+
max={4}
|
|
122
|
+
step={1}
|
|
123
|
+
value={value}
|
|
124
|
+
onChange={setValue}
|
|
125
|
+
valueText={SIGNAL_LABELS[value]}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const NonNumericValueText: Story = {
|
|
131
|
+
render: () => (
|
|
132
|
+
<div style={{ width: 280 }}>
|
|
133
|
+
<SignalSlider />
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
|
|
113
138
|
export const Inactive: Story = {
|
|
114
139
|
render: () => (
|
|
115
140
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: 280 }}>
|