@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,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Cross } from '@ds-mo/icons';
|
|
3
4
|
import { Surface } from '@/components/Surface';
|
|
4
5
|
import { Text } from '@/components/Text';
|
|
5
6
|
import { getCssTimeMs } from '@/utils/css-tokens';
|
|
@@ -22,6 +23,8 @@ export interface BannerProps {
|
|
|
22
23
|
className?: string;
|
|
23
24
|
floating?: boolean;
|
|
24
25
|
onDismiss?: () => void;
|
|
26
|
+
/** Accessible label for the dismiss button. Defaults to "Dismiss". */
|
|
27
|
+
dismissLabel?: string;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
const getContentColorVar = (intent: BannerIntent, contrast: BannerContrast): string => {
|
|
@@ -38,6 +41,7 @@ export const Banner: React.FC<BannerProps> = ({
|
|
|
38
41
|
className = '',
|
|
39
42
|
floating = false,
|
|
40
43
|
onDismiss,
|
|
44
|
+
dismissLabel = 'Dismiss',
|
|
41
45
|
}) => {
|
|
42
46
|
const toastDurationMs = getCssTimeMs('--effect-animation-delay-long-2', TOAST_DURATION_FALLBACK_MS);
|
|
43
47
|
const messageColor = getContentColorVar(intent, contrast);
|
|
@@ -45,18 +49,28 @@ export const Banner: React.FC<BannerProps> = ({
|
|
|
45
49
|
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
46
50
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
47
51
|
|
|
48
|
-
const
|
|
52
|
+
const beginClose = useCallback(() => {
|
|
49
53
|
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
|
|
50
54
|
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
|
51
|
-
dismissTimerRef.current =
|
|
52
|
-
|
|
55
|
+
dismissTimerRef.current = null;
|
|
56
|
+
if (floating) {
|
|
53
57
|
setIsClosing(true);
|
|
54
58
|
closeTimerRef.current = setTimeout(() => {
|
|
55
59
|
closeTimerRef.current = null;
|
|
56
60
|
onDismiss?.();
|
|
57
61
|
}, FADE_OUT_DURATION_MS);
|
|
62
|
+
} else {
|
|
63
|
+
onDismiss?.();
|
|
64
|
+
}
|
|
65
|
+
}, [floating, onDismiss]);
|
|
66
|
+
|
|
67
|
+
const scheduleDismiss = useCallback(() => {
|
|
68
|
+
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
|
|
69
|
+
dismissTimerRef.current = setTimeout(() => {
|
|
70
|
+
dismissTimerRef.current = null;
|
|
71
|
+
beginClose();
|
|
58
72
|
}, toastDurationMs);
|
|
59
|
-
}, [
|
|
73
|
+
}, [beginClose, toastDurationMs]);
|
|
60
74
|
|
|
61
75
|
useEffect(() => {
|
|
62
76
|
if (floating && message) scheduleDismiss();
|
|
@@ -66,10 +80,16 @@ export const Banner: React.FC<BannerProps> = ({
|
|
|
66
80
|
};
|
|
67
81
|
}, [floating, message, scheduleDismiss]);
|
|
68
82
|
|
|
83
|
+
const isAssertive = intent === 'negative';
|
|
84
|
+
const role = isAssertive ? 'alert' : 'status';
|
|
85
|
+
const ariaLive = isAssertive ? undefined : 'polite';
|
|
86
|
+
|
|
69
87
|
const content = (
|
|
70
88
|
<div
|
|
71
89
|
className={`${styles.bannerWrapper} ${className}`.trim()}
|
|
72
90
|
style={{ '--banner-content': messageColor } as React.CSSProperties}
|
|
91
|
+
role={role}
|
|
92
|
+
aria-live={ariaLive}
|
|
73
93
|
>
|
|
74
94
|
<Surface
|
|
75
95
|
elevation={floating ? 'floating' : 'elevated'}
|
|
@@ -87,6 +107,16 @@ export const Banner: React.FC<BannerProps> = ({
|
|
|
87
107
|
<Text variant="text-body-medium" as="span" className={styles.message}>
|
|
88
108
|
{message}
|
|
89
109
|
</Text>
|
|
110
|
+
{onDismiss && (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className={styles.dismiss}
|
|
114
|
+
onClick={beginClose}
|
|
115
|
+
aria-label={dismissLabel}
|
|
116
|
+
>
|
|
117
|
+
<Cross size={16} />
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
90
120
|
</div>
|
|
91
121
|
</Surface>
|
|
92
122
|
</div>
|
|
@@ -66,7 +66,7 @@ const ELEVATIONS: ButtonElevation[] = ['elevated', 'flat', 'none', 'floating'];
|
|
|
66
66
|
|
|
67
67
|
const col: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-start' };
|
|
68
68
|
const row: React.CSSProperties = { display: 'flex', gap: 8, alignItems: 'center' };
|
|
69
|
-
const lbl = (
|
|
69
|
+
const lbl = (_text: string): React.CSSProperties => ({ fontSize: 10, fontFamily: 'monospace', color: '#888', minWidth: 80, flexShrink: 0 });
|
|
70
70
|
const section = (text: string) => (
|
|
71
71
|
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase', color: '#555', marginTop: 8 }}>
|
|
72
72
|
{text}
|
|
@@ -14,13 +14,11 @@ export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
|
|
|
14
14
|
export type ButtonContrast = 'strong' | 'bold' | 'medium' | 'faint';
|
|
15
15
|
export type ButtonBackground = 'faint' | 'medium' | 'bold' | 'strong' | 'always-dark';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
interface ButtonBaseProps {
|
|
18
18
|
/** Polymorphic element type. Defaults to 'button'. */
|
|
19
19
|
as?: React.ElementType;
|
|
20
20
|
/** Visual variant. Defaults to 'primary'. */
|
|
21
21
|
variant?: ButtonVariant;
|
|
22
|
-
/** Label text. */
|
|
23
|
-
label?: string;
|
|
24
22
|
/** Leading icon component from @ds-mo/icons. */
|
|
25
23
|
icon?: IconComponent;
|
|
26
24
|
/** Semantic intent. Defaults to 'brand'. */
|
|
@@ -67,12 +65,24 @@ export interface ButtonProps {
|
|
|
67
65
|
inactive?: boolean;
|
|
68
66
|
type?: 'button' | 'submit' | 'reset';
|
|
69
67
|
id?: string;
|
|
70
|
-
'aria-label'?: string;
|
|
71
68
|
href?: string;
|
|
72
69
|
target?: string;
|
|
73
70
|
rel?: string;
|
|
74
71
|
}
|
|
75
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Accessible-name requirement: a Button must expose text via `label`,
|
|
75
|
+
* `aria-label`, or `aria-labelledby`. Icon-only buttons that omit all three
|
|
76
|
+
* fail at the call site (TypeScript), with a runtime warning as a soft
|
|
77
|
+
* fallback for callers that bypass the type system.
|
|
78
|
+
*/
|
|
79
|
+
type ButtonAccessibleNameProps =
|
|
80
|
+
| { label: string; 'aria-label'?: string; 'aria-labelledby'?: string }
|
|
81
|
+
| { label?: string; 'aria-label': string; 'aria-labelledby'?: string }
|
|
82
|
+
| { label?: string; 'aria-label'?: string; 'aria-labelledby': string };
|
|
83
|
+
|
|
84
|
+
export type ButtonProps = ButtonBaseProps & ButtonAccessibleNameProps;
|
|
85
|
+
|
|
76
86
|
const ICON_SIZE_MAP: Record<ButtonSize, number> = { xs: 12, sm: 16, md: 20, lg: 24 };
|
|
77
87
|
|
|
78
88
|
const TEXT_STYLE_MAP: Record<ButtonSize, string> = {
|
|
@@ -84,8 +94,8 @@ const TEXT_STYLE_MAP: Record<ButtonSize, string> = {
|
|
|
84
94
|
|
|
85
95
|
|
|
86
96
|
export const Button = forwardRef<HTMLElement, ButtonProps>(
|
|
87
|
-
(
|
|
88
|
-
{
|
|
97
|
+
(props, ref) => {
|
|
98
|
+
const {
|
|
89
99
|
as,
|
|
90
100
|
variant = 'primary',
|
|
91
101
|
label,
|
|
@@ -109,14 +119,16 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(
|
|
|
109
119
|
type = 'button',
|
|
110
120
|
id,
|
|
111
121
|
'aria-label': ariaLabel,
|
|
122
|
+
'aria-labelledby': ariaLabelledBy,
|
|
112
123
|
href,
|
|
113
124
|
target,
|
|
114
125
|
rel,
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
) => {
|
|
126
|
+
} = props;
|
|
127
|
+
|
|
118
128
|
const Component = (as ?? 'button') as React.ElementType;
|
|
119
129
|
const isButton = !as || as === 'button';
|
|
130
|
+
const isLink = as === 'a';
|
|
131
|
+
const isCustomInteractive = !isButton && !isLink;
|
|
120
132
|
const isInteractive = !inactive && !loading;
|
|
121
133
|
|
|
122
134
|
const hasIcon = !!Icon;
|
|
@@ -153,7 +165,11 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(
|
|
|
153
165
|
className,
|
|
154
166
|
);
|
|
155
167
|
|
|
156
|
-
|
|
168
|
+
if (isIconOnly && !ariaLabel && !ariaLabelledBy) {
|
|
169
|
+
console.warn(
|
|
170
|
+
'[@ds-mo/ui Button] Icon-only Button is missing an accessible name. Provide `label`, `aria-label`, or `aria-labelledby`.'
|
|
171
|
+
);
|
|
172
|
+
}
|
|
157
173
|
|
|
158
174
|
const extraProps: Record<string, unknown> = {};
|
|
159
175
|
if (isButton) {
|
|
@@ -164,6 +180,19 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(
|
|
|
164
180
|
if (target) extraProps.target = target;
|
|
165
181
|
if (rel) extraProps.rel = rel;
|
|
166
182
|
|
|
183
|
+
if (isCustomInteractive) {
|
|
184
|
+
extraProps.role = 'button';
|
|
185
|
+
extraProps.tabIndex = isInteractive ? 0 : -1;
|
|
186
|
+
if (isInteractive) {
|
|
187
|
+
extraProps.onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
|
|
188
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
(e.currentTarget as HTMLElement).click();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
167
196
|
return (
|
|
168
197
|
<Component
|
|
169
198
|
ref={ref}
|
|
@@ -173,7 +202,9 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(
|
|
|
173
202
|
onClick={isInteractive ? onClick : undefined}
|
|
174
203
|
onMouseEnter={isInteractive ? onMouseEnter : undefined}
|
|
175
204
|
onMouseLeave={isInteractive ? onMouseLeave : undefined}
|
|
176
|
-
aria-label={
|
|
205
|
+
aria-label={ariaLabel}
|
|
206
|
+
aria-labelledby={ariaLabelledBy}
|
|
207
|
+
aria-disabled={inactive || loading || undefined}
|
|
177
208
|
aria-busy={loading || undefined}
|
|
178
209
|
{...extraProps}
|
|
179
210
|
>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef } from 'react';
|
|
1
|
+
import React, { forwardRef, useId } from 'react';
|
|
2
2
|
import { cn } from '@/utils/cn';
|
|
3
3
|
import { Text } from '@/components/Text';
|
|
4
4
|
import type { IconComponent } from '@/types/icons';
|
|
@@ -34,6 +34,7 @@ export const Checkbox = forwardRef<HTMLDivElement, CheckboxProps>(
|
|
|
34
34
|
},
|
|
35
35
|
ref
|
|
36
36
|
) => {
|
|
37
|
+
const labelId = useId();
|
|
37
38
|
const handleClick = () => {
|
|
38
39
|
if (!inactive) onChange?.(!checked);
|
|
39
40
|
};
|
|
@@ -46,7 +47,9 @@ export const Checkbox = forwardRef<HTMLDivElement, CheckboxProps>(
|
|
|
46
47
|
ref={ref}
|
|
47
48
|
role="checkbox"
|
|
48
49
|
aria-checked={indeterminate ? 'mixed' : checked}
|
|
49
|
-
aria-
|
|
50
|
+
aria-disabled={inactive || undefined}
|
|
51
|
+
aria-labelledby={labelId}
|
|
52
|
+
data-inactive={inactive || undefined}
|
|
50
53
|
tabIndex={inactive ? -1 : 0}
|
|
51
54
|
className={cn(styles.checkbox, inactive && styles.inactive, className)}
|
|
52
55
|
onClick={handleClick}
|
|
@@ -57,7 +60,7 @@ export const Checkbox = forwardRef<HTMLDivElement, CheckboxProps>(
|
|
|
57
60
|
{(checked || indeterminate) && <span className={styles.checkmark}>{indeterminate ? '−' : '✓'}</span>}
|
|
58
61
|
</span>
|
|
59
62
|
)}
|
|
60
|
-
<Text variant="text-body-medium" as="span" color="inherit">
|
|
63
|
+
<Text id={labelId} variant="text-body-medium" as="span" color="inherit">
|
|
61
64
|
{label}
|
|
62
65
|
</Text>
|
|
63
66
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef } from 'react';
|
|
1
|
+
import React, { forwardRef, useId, isValidElement, cloneElement } from 'react';
|
|
2
2
|
import { cn } from '@/utils/cn';
|
|
3
3
|
import { Text } from '@/components/Text';
|
|
4
4
|
import styles from './Field.module.css';
|
|
@@ -11,12 +11,25 @@ export interface FieldProps {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export const Field = forwardRef<HTMLDivElement, FieldProps>(
|
|
14
|
-
({ label, children, id, className }, ref) =>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
({ label, children, id, className }, ref) => {
|
|
15
|
+
const generatedId = useId();
|
|
16
|
+
const fieldId = id ?? generatedId;
|
|
17
|
+
|
|
18
|
+
let content = children;
|
|
19
|
+
if (isValidElement(children)) {
|
|
20
|
+
const childProps = children.props as { id?: string };
|
|
21
|
+
if (childProps.id === undefined) {
|
|
22
|
+
content = cloneElement(children as React.ReactElement<{ id?: string }>, { id: fieldId });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div ref={ref} className={cn(styles.field, className)}>
|
|
28
|
+
<Text variant="text-body-small-emphasis" as="label" htmlFor={fieldId}>{label}</Text>
|
|
29
|
+
{content}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
20
33
|
);
|
|
21
34
|
|
|
22
35
|
Field.displayName = 'Field';
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--dimension-space-050);
|
|
5
|
+
width: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
.wrapper {
|
|
2
9
|
width: 100%;
|
|
3
10
|
height: var(--dimension-size-400);
|
|
@@ -13,6 +20,20 @@
|
|
|
13
20
|
box-shadow: inset 0 0 0 1.25px var(--color-foreground-bold-brand);
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
.wrapperError {
|
|
24
|
+
box-shadow: inset 0 0 0 1.25px var(--color-border-bold-negative);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.wrapperError:focus-within {
|
|
28
|
+
box-shadow: inset 0 0 0 1.25px var(--color-border-bold-negative);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.errorText {
|
|
32
|
+
font-size: var(--typography-fontsize-sm);
|
|
33
|
+
line-height: var(--typography-lineheight-sm);
|
|
34
|
+
color: var(--color-foreground-bold-negative);
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
.row {
|
|
17
38
|
display: flex;
|
|
18
39
|
align-items: center;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef } from 'react';
|
|
1
|
+
import React, { forwardRef, useEffect, useId } from 'react';
|
|
2
2
|
import { cn } from '@/utils/cn';
|
|
3
3
|
import type { IconComponent } from '@/types/icons';
|
|
4
4
|
import styles from './Input.module.css';
|
|
@@ -20,8 +20,19 @@ export interface InputProps {
|
|
|
20
20
|
/** Icon for clear button (search type). */
|
|
21
21
|
clearIcon?: IconComponent;
|
|
22
22
|
'aria-label'?: string;
|
|
23
|
+
'aria-labelledby'?: string;
|
|
24
|
+
'aria-describedby'?: string;
|
|
25
|
+
/** Marks the input as invalid; sets `aria-invalid` and applies error styling. */
|
|
26
|
+
error?: boolean;
|
|
27
|
+
/** Message rendered below the input when `error` is true; auto-linked via `aria-describedby`. */
|
|
28
|
+
errorMessage?: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
const isDevEnv = (): boolean => {
|
|
32
|
+
const meta = import.meta as { env?: { DEV?: boolean } };
|
|
33
|
+
return Boolean(meta.env?.DEV);
|
|
34
|
+
};
|
|
35
|
+
|
|
25
36
|
export const Input = forwardRef<HTMLDivElement, InputProps>(
|
|
26
37
|
(
|
|
27
38
|
{
|
|
@@ -37,37 +48,67 @@ export const Input = forwardRef<HTMLDivElement, InputProps>(
|
|
|
37
48
|
suffix,
|
|
38
49
|
clearIcon: ClearIcon,
|
|
39
50
|
'aria-label': ariaLabel,
|
|
51
|
+
'aria-labelledby': ariaLabelledBy,
|
|
52
|
+
'aria-describedby': ariaDescribedBy,
|
|
53
|
+
error = false,
|
|
54
|
+
errorMessage,
|
|
40
55
|
},
|
|
41
56
|
ref
|
|
42
57
|
) => {
|
|
58
|
+
const reactId = useId();
|
|
59
|
+
const errorId = `${reactId}-error`;
|
|
43
60
|
const showClear = type === 'search' && value.length > 0 && !inactive;
|
|
61
|
+
const showErrorMessage = error && Boolean(errorMessage);
|
|
62
|
+
|
|
63
|
+
const describedBy =
|
|
64
|
+
[ariaDescribedBy, showErrorMessage ? errorId : undefined].filter(Boolean).join(' ') ||
|
|
65
|
+
undefined;
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!isDevEnv()) return;
|
|
69
|
+
if (!id && !ariaLabel && !ariaLabelledBy) {
|
|
70
|
+
console.warn(
|
|
71
|
+
'[@ds-mo/ui] <Input> is missing an accessible name. Provide one of: `id` (paired with a <label htmlFor>), `aria-label`, or `aria-labelledby`.'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}, [id, ariaLabel, ariaLabelledBy]);
|
|
44
75
|
|
|
45
76
|
const handleClear = () => {
|
|
46
77
|
onChange({ target: { value: '' } } as React.ChangeEvent<HTMLInputElement>);
|
|
47
78
|
};
|
|
48
79
|
|
|
49
80
|
return (
|
|
50
|
-
<div
|
|
51
|
-
<div className={styles.
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
<div className={styles.container}>
|
|
82
|
+
<div ref={ref} className={cn(styles.wrapper, error && styles.wrapperError, className)}>
|
|
83
|
+
<div className={styles.row}>
|
|
84
|
+
<input
|
|
85
|
+
type={type}
|
|
86
|
+
id={id}
|
|
87
|
+
value={value}
|
|
88
|
+
onChange={onChange}
|
|
89
|
+
onKeyDown={onKeyDown}
|
|
90
|
+
placeholder={placeholder}
|
|
91
|
+
className={styles.input}
|
|
92
|
+
autoFocus={autoFocus}
|
|
93
|
+
disabled={inactive}
|
|
94
|
+
aria-label={ariaLabel}
|
|
95
|
+
aria-labelledby={ariaLabelledBy}
|
|
96
|
+
aria-describedby={describedBy}
|
|
97
|
+
aria-invalid={error || undefined}
|
|
98
|
+
/>
|
|
99
|
+
{showClear && (
|
|
100
|
+
<button type="button" className={styles.clearButton} onClick={handleClear} aria-label="Clear">
|
|
101
|
+
{ClearIcon ? <ClearIcon size={16} /> : <span aria-hidden>×</span>}
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
{suffix && <div className={styles.suffix}>{suffix}</div>}
|
|
105
|
+
</div>
|
|
70
106
|
</div>
|
|
107
|
+
{showErrorMessage && (
|
|
108
|
+
<div id={errorId} role="alert" className={styles.errorText}>
|
|
109
|
+
{errorMessage}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
71
112
|
</div>
|
|
72
113
|
);
|
|
73
114
|
}
|
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
animation: spin 0.75s linear infinite;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
.status {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.visuallyHidden {
|
|
12
|
+
position: absolute;
|
|
13
|
+
width: 1px;
|
|
14
|
+
height: 1px;
|
|
15
|
+
padding: 0;
|
|
16
|
+
margin: -1px;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
clip: rect(0, 0, 0, 0);
|
|
19
|
+
white-space: nowrap;
|
|
20
|
+
border: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
@keyframes spin {
|
|
7
24
|
from { transform: rotate(0deg); }
|
|
8
25
|
to { transform: rotate(360deg); }
|
|
@@ -14,6 +14,15 @@ type Story = StoryObj<typeof Loader>;
|
|
|
14
14
|
|
|
15
15
|
export const Default: Story = {};
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Standalone usage with an accessible label. Wraps the spinner in a
|
|
19
|
+
* `role="status"` live region and renders a visually-hidden label so
|
|
20
|
+
* screen readers announce the loading state on mount.
|
|
21
|
+
*/
|
|
22
|
+
export const WithLabel: Story = {
|
|
23
|
+
args: { label: 'Loading' },
|
|
24
|
+
};
|
|
25
|
+
|
|
17
26
|
/** Shows how stroke weight scales naturally with size — same as icon strokes. */
|
|
18
27
|
export const Sizes: Story = {
|
|
19
28
|
render: () => (
|
|
@@ -9,6 +9,17 @@ export interface LoaderProps {
|
|
|
9
9
|
*/
|
|
10
10
|
size?: number;
|
|
11
11
|
className?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Accessible label announced by assistive tech when the Loader is used
|
|
14
|
+
* standalone. When provided, the spinner is wrapped in a live region
|
|
15
|
+
* (`role="status"`, `aria-live="polite"`) and the label is rendered
|
|
16
|
+
* visually-hidden so screen readers announce it on mount.
|
|
17
|
+
*
|
|
18
|
+
* Omit when the Loader is rendered inside a host that already conveys
|
|
19
|
+
* busy state (e.g. a Button with `aria-busy`) — the spinner stays
|
|
20
|
+
* `aria-hidden` to avoid double-announcing.
|
|
21
|
+
*/
|
|
22
|
+
label?: string;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
/**
|
|
@@ -19,25 +30,36 @@ export interface LoaderProps {
|
|
|
19
30
|
* - r=5 → 10px circle in a 16px box (3px optical padding each side)
|
|
20
31
|
* - strokeWidth=1 at native size → scales to 1.25px at 20px, 1.5px at 24px
|
|
21
32
|
*/
|
|
22
|
-
export const Loader = ({ size = 20, className }: LoaderProps) =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
export const Loader = ({ size = 20, className, label }: LoaderProps) => {
|
|
34
|
+
const svg = (
|
|
35
|
+
<svg
|
|
36
|
+
width={size}
|
|
37
|
+
height={size}
|
|
38
|
+
viewBox="0 0 16 16"
|
|
39
|
+
fill="none"
|
|
40
|
+
className={`${styles.loader}${className ? ` ${className}` : ''}`}
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
>
|
|
43
|
+
<circle
|
|
44
|
+
cx="8"
|
|
45
|
+
cy="8"
|
|
46
|
+
r="5"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
strokeWidth="1"
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
strokeDasharray="23.6 7.9"
|
|
51
|
+
/>
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (label === undefined) return svg;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<span role="status" aria-live="polite" className={styles.status}>
|
|
59
|
+
{svg}
|
|
60
|
+
<span className={styles.visuallyHidden}>{label}</span>
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
42
64
|
|
|
43
65
|
Loader.displayName = 'Loader';
|
|
@@ -7,25 +7,30 @@ export interface DestructiveMenuItemProps {
|
|
|
7
7
|
onClick: () => void;
|
|
8
8
|
holdDuration?: number;
|
|
9
9
|
subtext?: string;
|
|
10
|
+
tabIndex?: number;
|
|
11
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
|
12
|
+
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
export const DestructiveMenuItem = forwardRef<HTMLButtonElement, DestructiveMenuItemProps>(
|
|
13
|
-
({ label, onClick, holdDuration = 4000, subtext }, ref) => {
|
|
16
|
+
({ label, onClick, holdDuration = 4000, subtext, tabIndex, onKeyDown, onFocus }, ref) => {
|
|
14
17
|
const [progress, setProgress] = useState(0);
|
|
15
18
|
const pressTimerRef = useRef<number | null>(null);
|
|
16
19
|
const startTimeRef = useRef<number>(0);
|
|
17
20
|
const animationFrameRef = useRef<number | null>(null);
|
|
21
|
+
const isHoldingRef = useRef(false);
|
|
18
22
|
|
|
19
23
|
const resetState = () => {
|
|
20
24
|
setProgress(0);
|
|
25
|
+
isHoldingRef.current = false;
|
|
21
26
|
if (pressTimerRef.current) { clearTimeout(pressTimerRef.current); pressTimerRef.current = null; }
|
|
22
27
|
if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; }
|
|
23
28
|
};
|
|
24
29
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (holdDuration === 0) { onClick(); return; }
|
|
30
|
+
const startHold = () => {
|
|
31
|
+
if (isHoldingRef.current) return;
|
|
32
|
+
isHoldingRef.current = true;
|
|
33
|
+
if (holdDuration === 0) { onClick(); resetState(); return; }
|
|
29
34
|
startTimeRef.current = performance.now();
|
|
30
35
|
const animate = () => {
|
|
31
36
|
const elapsed = performance.now() - startTimeRef.current;
|
|
@@ -37,9 +42,42 @@ export const DestructiveMenuItem = forwardRef<HTMLButtonElement, DestructiveMenu
|
|
|
37
42
|
pressTimerRef.current = window.setTimeout(() => { onClick(); resetState(); }, holdDuration);
|
|
38
43
|
};
|
|
39
44
|
|
|
45
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
startHold();
|
|
49
|
+
};
|
|
50
|
+
|
|
40
51
|
const handleMouseUp = (e: React.MouseEvent) => { e.stopPropagation(); resetState(); };
|
|
41
52
|
const handleMouseLeave = (e: React.MouseEvent) => { e.stopPropagation(); resetState(); };
|
|
42
53
|
|
|
54
|
+
const isSpaceKey = (e: React.KeyboardEvent) => e.key === ' ' || e.key === 'Spacebar';
|
|
55
|
+
|
|
56
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
57
|
+
if (isSpaceKey(e)) {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
if (e.repeat) return;
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
startHold();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (e.key === 'Enter') {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
onKeyDown?.(e);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
72
|
+
if (isSpaceKey(e)) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
resetState();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleBlur = () => { resetState(); };
|
|
80
|
+
|
|
43
81
|
useEffect(() => () => { resetState(); }, []);
|
|
44
82
|
|
|
45
83
|
return (
|
|
@@ -49,9 +87,24 @@ export const DestructiveMenuItem = forwardRef<HTMLButtonElement, DestructiveMenu
|
|
|
49
87
|
onMouseDown={handleMouseDown}
|
|
50
88
|
onMouseUp={handleMouseUp}
|
|
51
89
|
onMouseLeave={handleMouseLeave}
|
|
90
|
+
onKeyDown={handleKeyDown}
|
|
91
|
+
onKeyUp={handleKeyUp}
|
|
92
|
+
onBlur={handleBlur}
|
|
93
|
+
onFocus={onFocus}
|
|
52
94
|
type="button"
|
|
95
|
+
role="menuitem"
|
|
96
|
+
tabIndex={tabIndex ?? -1}
|
|
97
|
+
aria-keyshortcuts="Space"
|
|
53
98
|
>
|
|
54
|
-
<div
|
|
99
|
+
<div
|
|
100
|
+
className={styles.progressFill}
|
|
101
|
+
style={{ width: `${progress}%` }}
|
|
102
|
+
role="progressbar"
|
|
103
|
+
aria-valuenow={Math.round(progress)}
|
|
104
|
+
aria-valuemin={0}
|
|
105
|
+
aria-valuemax={100}
|
|
106
|
+
aria-label={`Hold to confirm ${label}`}
|
|
107
|
+
/>
|
|
55
108
|
<div className={styles.content}>
|
|
56
109
|
<Text variant="text-body-medium" as="span" className={styles.label}>{label}</Text>
|
|
57
110
|
{subtext && <Text variant="text-body-small" as="span" className={styles.subtext}>{subtext}</Text>}
|