@drivenets/design-system 0.8.0 → 0.9.1
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/CHANGELOG.md +30 -0
- package/dist/index.cjs +1567 -1077
- package/dist/index.d.cts +857 -135
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +857 -135
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1555 -1079
- package/dist/index.js.map +1 -1
- package/dist/index.min.css +1 -1
- package/package.json +19 -17
- package/src/components/ds-autocomplete/ds-autocomplete.module.scss +0 -1
- package/src/components/ds-button/versions/ds-button-new/ds-button-new.module.scss +25 -9
- package/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx +5 -5
- package/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts +11 -1
- package/src/components/ds-button/versions/ds-button-new/index.ts +4 -1
- package/src/components/ds-button-v3/ds-button-v3.module.scss +391 -0
- package/src/components/ds-button-v3/ds-button-v3.tsx +55 -0
- package/src/components/ds-button-v3/ds-button-v3.types.ts +58 -0
- package/src/components/ds-button-v3/index.ts +6 -0
- package/src/components/ds-comment-card/ds-comment-card.module.scss +2 -2
- package/src/components/ds-comments-drawer/components/comments-filter-modal/comments-filter-modal.tsx +1 -1
- package/src/components/ds-comments-drawer/ds-comments-drawer.tsx +3 -9
- package/src/components/ds-date-picker/ds-date-picker.module.scss +10 -0
- package/src/components/ds-date-picker/ds-date-picker.tsx +4 -2
- package/src/components/ds-date-range-picker/ds-date-range-picker.tsx +2 -2
- package/src/components/ds-drawer/ds-drawer.tsx +6 -9
- package/src/components/ds-drawer/ds-drawer.types.ts +4 -2
- package/src/components/ds-form-control/ds-form-control.tsx +2 -3
- package/src/components/ds-panel/ds-panel.module.scss +4 -1
- package/src/components/ds-panel/ds-panel.tsx +8 -3
- package/src/components/ds-panel/ds-panel.types.ts +8 -1
- package/src/components/ds-panel/index.ts +4 -1
- package/src/components/ds-select/ds-select.module.scss +4 -1
- package/src/components/ds-split-button/ds-split-button.module.scss +99 -0
- package/src/components/ds-split-button/ds-split-button.tsx +49 -0
- package/src/components/ds-split-button/ds-split-button.types.ts +25 -0
- package/src/components/ds-split-button/index.ts +10 -0
- package/src/components/ds-stack/ds-stack.module.scss +4 -0
- package/src/components/ds-stack/ds-stack.tsx +35 -0
- package/src/components/ds-stack/ds-stack.types.ts +19 -0
- package/src/components/ds-stack/index.ts +18 -0
- package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.tsx +2 -2
- package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.module.scss +5 -8
- package/src/components/ds-table/components/ds-table-cell/ds-table-cell.tsx +7 -4
- package/src/components/ds-table/components/ds-table-cell/ds-table-cell.types.ts +6 -0
- package/src/components/ds-table/ds-table.module.scss +6 -0
- package/src/components/ds-table/ds-table.tsx +3 -0
- package/src/components/ds-time-picker/ds-time-picker.module.scss +11 -0
- package/src/components/ds-time-picker/ds-time-picker.tsx +32 -12
- package/src/components/ds-time-picker/ds-time-picker.types.ts +6 -0
- package/src/components/ds-tree/ds-tree.types.ts +2 -2
- package/src/index.ts +4 -0
- package/src/styles/_breakpoints.module.scss +5 -0
- package/src/styles/_grid.scss +5 -1
- package/src/styles/_mixin.scss +2 -2
- package/src/styles/_responsive.scss +13 -0
- package/src/styles/_root_updated.scss +20 -20
- package/src/styles/_scrollbars.scss +6 -1
- package/src/styles/_variables.scss +2 -0
- package/src/styles/shared/_button.scss +2 -0
- package/src/styles/shared/_input.scss +3 -40
- package/src/utils/responsive.browser.test.ts +94 -0
- package/src/utils/responsive.ts +79 -0
- package/src/utils/responsive.unit.test.ts +54 -0
- package/src/utils/type-utils.ts +1 -0
- package/src/utils/use-controlled.browser.test.ts +6 -6
- package/src/utils/use-controlled.ts +1 -1
|
@@ -19,13 +19,16 @@ export const DsTableCell = <TData, TValue>({
|
|
|
19
19
|
primaryRowActions = [],
|
|
20
20
|
secondaryRowActions = [],
|
|
21
21
|
}: DsTableCellProps<TData, TValue>) => {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const visiblePrimary = primaryRowActions.filter((action) => !action.hidden?.(row.original));
|
|
23
|
+
const visibleSecondary = secondaryRowActions.filter((action) => !action.hidden?.(row.original));
|
|
24
|
+
|
|
25
|
+
if (visiblePrimary.length || visibleSecondary.length) {
|
|
26
|
+
const hasSecondaryRowActions = visibleSecondary.length > 0;
|
|
24
27
|
return (
|
|
25
28
|
<div className={styles.lastCell}>
|
|
26
29
|
<DsDefaultTableCell cell={cell} />
|
|
27
30
|
<div className={styles.cellActions}>
|
|
28
|
-
{
|
|
31
|
+
{visiblePrimary.map((action, i) => {
|
|
29
32
|
const isDisabled = action.disabled?.(row.original);
|
|
30
33
|
const label = typeof action.label === 'function' ? action.label(row.original) : action.label;
|
|
31
34
|
return (
|
|
@@ -67,7 +70,7 @@ export const DsTableCell = <TData, TValue>({
|
|
|
67
70
|
</button>
|
|
68
71
|
</DsDropdownMenu.Trigger>
|
|
69
72
|
<DsDropdownMenu.Content>
|
|
70
|
-
{
|
|
73
|
+
{visibleSecondary.map((action, i) => {
|
|
71
74
|
const label =
|
|
72
75
|
typeof action.label === 'function' ? action.label(row.original) : action.label;
|
|
73
76
|
const isDisabled = action.disabled?.(row.original);
|
|
@@ -20,6 +20,12 @@ export interface RowAction<TData> {
|
|
|
20
20
|
*/
|
|
21
21
|
tooltip?: string;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Optional function to determine if the action should be hidden for a specific row.
|
|
25
|
+
* When it returns `true`, the action is omitted from that row's menu. Takes precedence over `disabled`.
|
|
26
|
+
*/
|
|
27
|
+
hidden?: (row: TData) => boolean;
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* Optional function to determine if the action should be disabled for a specific row
|
|
25
31
|
*/
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
@use '../../styles/utilities' as utils;
|
|
3
3
|
@use './styles/variables' as vars;
|
|
4
4
|
|
|
5
|
+
$bulk-actions-height: 60px;
|
|
6
|
+
|
|
5
7
|
.container {
|
|
6
8
|
border-radius: vars.$border-radius;
|
|
7
9
|
border-width: vars.$border-width;
|
|
@@ -45,3 +47,7 @@
|
|
|
45
47
|
height: vars.$virtualized-height;
|
|
46
48
|
overflow: auto;
|
|
47
49
|
}
|
|
50
|
+
|
|
51
|
+
.bulkActionsVisible {
|
|
52
|
+
padding-bottom: $bulk-actions-height;
|
|
53
|
+
}
|
|
@@ -204,6 +204,8 @@ const DsTable = <TData extends { id: string }, TValue>({
|
|
|
204
204
|
.map(([key]) => rowsById[key]?.original)
|
|
205
205
|
.filter((v) => !!v);
|
|
206
206
|
|
|
207
|
+
const isBulkActionsVisible = selectable && actions.length > 0 && selectedRows.length > 0;
|
|
208
|
+
|
|
207
209
|
const contextValue: DsTableContextType<TData, TValue> = {
|
|
208
210
|
stickyHeader,
|
|
209
211
|
bordered,
|
|
@@ -230,6 +232,7 @@ const DsTable = <TData extends { id: string }, TValue>({
|
|
|
230
232
|
styles.container,
|
|
231
233
|
!virtualized && styles.dataTableContainer,
|
|
232
234
|
virtualized && styles.virtualizedContainer,
|
|
235
|
+
isBulkActionsVisible && styles.bulkActionsVisible,
|
|
233
236
|
className,
|
|
234
237
|
)}
|
|
235
238
|
>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ChangeEvent, Fragment, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { Popover } from '@ark-ui/react/popover';
|
|
3
3
|
import { Portal } from '@ark-ui/react/portal';
|
|
4
|
+
import classNames from 'classnames';
|
|
4
5
|
import type { DsTimePickerProps } from './ds-time-picker.types';
|
|
5
6
|
import { clampTime, formatTime, parseTime, timeScrollerAdapter } from './ds-time-picker.utils';
|
|
6
7
|
import { TimeScroller } from './components/time-scroller/time-scroller';
|
|
@@ -8,6 +9,7 @@ import { DsIcon } from '../ds-icon';
|
|
|
8
9
|
import { DsButton } from '../ds-button';
|
|
9
10
|
import { DsTextInput } from '../ds-text-input';
|
|
10
11
|
import { useControlled } from '../../utils/use-controlled';
|
|
12
|
+
import styles from './ds-time-picker.module.scss';
|
|
11
13
|
|
|
12
14
|
const DsTimePicker = (props: DsTimePickerProps) => {
|
|
13
15
|
const {
|
|
@@ -19,6 +21,7 @@ const DsTimePicker = (props: DsTimePickerProps) => {
|
|
|
19
21
|
max,
|
|
20
22
|
disabled,
|
|
21
23
|
readOnly,
|
|
24
|
+
hideClearButton = false,
|
|
22
25
|
disablePortal = false,
|
|
23
26
|
locale,
|
|
24
27
|
slotProps,
|
|
@@ -103,6 +106,8 @@ const DsTimePicker = (props: DsTimePickerProps) => {
|
|
|
103
106
|
setIsOpen(details.open);
|
|
104
107
|
};
|
|
105
108
|
|
|
109
|
+
const showClearButton = !hideClearButton && !disabled && !readOnly && !!value;
|
|
110
|
+
|
|
106
111
|
const Wrapper = disablePortal ? Fragment : Portal;
|
|
107
112
|
|
|
108
113
|
return (
|
|
@@ -112,7 +117,7 @@ const DsTimePicker = (props: DsTimePickerProps) => {
|
|
|
112
117
|
positioning={{ placement: 'bottom-start', gutter: 4 }}
|
|
113
118
|
>
|
|
114
119
|
<Popover.Anchor asChild>
|
|
115
|
-
<div ref={ref} className={className}>
|
|
120
|
+
<div ref={ref} className={classNames(styles.root, className)}>
|
|
116
121
|
<DsTextInput
|
|
117
122
|
ref={inputRef}
|
|
118
123
|
id={id}
|
|
@@ -126,17 +131,32 @@ const DsTimePicker = (props: DsTimePickerProps) => {
|
|
|
126
131
|
{...slotProps?.input}
|
|
127
132
|
slots={{
|
|
128
133
|
endAdornment: slotProps?.input?.slots?.endAdornment ?? (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
<>
|
|
135
|
+
{showClearButton && (
|
|
136
|
+
<DsButton
|
|
137
|
+
className={styles.clearTrigger}
|
|
138
|
+
design="v1.2"
|
|
139
|
+
size="tiny"
|
|
140
|
+
buttonType="tertiary"
|
|
141
|
+
disabled={disabled}
|
|
142
|
+
aria-label={locale?.clearLabel ?? 'Clear time'}
|
|
143
|
+
onClick={() => setValue(null)}
|
|
144
|
+
>
|
|
145
|
+
<DsIcon icon="close" size="tiny" />
|
|
146
|
+
</DsButton>
|
|
147
|
+
)}
|
|
148
|
+
<Popover.Trigger asChild>
|
|
149
|
+
<DsButton
|
|
150
|
+
design="v1.2"
|
|
151
|
+
size="tiny"
|
|
152
|
+
buttonType="tertiary"
|
|
153
|
+
disabled={disabled || readOnly}
|
|
154
|
+
aria-label={locale?.openLabel ?? 'Open time picker'}
|
|
155
|
+
>
|
|
156
|
+
<DsIcon icon="schedule" variant="outlined" size="tiny" />
|
|
157
|
+
</DsButton>
|
|
158
|
+
</Popover.Trigger>
|
|
159
|
+
</>
|
|
140
160
|
),
|
|
141
161
|
...slotProps?.input?.slots,
|
|
142
162
|
}}
|
|
@@ -21,6 +21,12 @@ export interface DsTimePickerProps {
|
|
|
21
21
|
disabled?: boolean;
|
|
22
22
|
readOnly?: boolean;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Whether to hide the clear button
|
|
26
|
+
* @default false
|
|
27
|
+
*/
|
|
28
|
+
hideClearButton?: boolean;
|
|
29
|
+
|
|
24
30
|
/**
|
|
25
31
|
* Whether to disable the portal for the popover content
|
|
26
32
|
* @default false
|
|
@@ -15,12 +15,12 @@ export interface DsTreeNode {
|
|
|
15
15
|
export const dsTreeSizes = ['medium', 'small'] as const;
|
|
16
16
|
export type DsTreeSize = (typeof dsTreeSizes)[number];
|
|
17
17
|
|
|
18
|
-
interface DsTreeBaseProps {
|
|
18
|
+
export interface DsTreeBaseProps {
|
|
19
19
|
className?: string;
|
|
20
20
|
style?: CSSProperties;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
interface DsTreeBasePropsWithChildren extends DsTreeBaseProps {
|
|
23
|
+
export interface DsTreeBasePropsWithChildren extends DsTreeBaseProps {
|
|
24
24
|
children?: ReactNode;
|
|
25
25
|
}
|
|
26
26
|
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from './components/ds-avatar';
|
|
|
4
4
|
export * from './components/ds-avatar-group';
|
|
5
5
|
export * from './components/ds-breadcrumb';
|
|
6
6
|
export * from './components/ds-button';
|
|
7
|
+
export * from './components/ds-button-v3';
|
|
7
8
|
export * from './components/ds-card';
|
|
8
9
|
export * from './components/ds-checkbox';
|
|
9
10
|
export * from './components/ds-chip';
|
|
@@ -42,6 +43,8 @@ export * from './components/ds-select';
|
|
|
42
43
|
export * from './components/ds-skeleton';
|
|
43
44
|
export * from './components/ds-smart-tabs';
|
|
44
45
|
export * from './components/ds-spinner';
|
|
46
|
+
export * from './components/ds-split-button';
|
|
47
|
+
export * from './components/ds-stack';
|
|
45
48
|
export * from './components/ds-status-badge';
|
|
46
49
|
export * from './components/ds-stepper';
|
|
47
50
|
export * from './components/ds-system-status';
|
|
@@ -58,3 +61,4 @@ export * from './components/ds-tooltip';
|
|
|
58
61
|
export * from './components/ds-tree';
|
|
59
62
|
export * from './components/ds-typography';
|
|
60
63
|
export * from './components/ds-vertical-tabs';
|
|
64
|
+
export * from './utils/responsive';
|
package/src/styles/_grid.scss
CHANGED
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
@for $i from 1 through grid-vars.$columns {
|
|
20
20
|
// Margins are added to all column spans to ensure proper alignment
|
|
21
21
|
// The last column span (full width) receives 2 margins (left + right), others receive 1 margin (typically left)
|
|
22
|
-
$margins:
|
|
22
|
+
$margins: 1;
|
|
23
|
+
|
|
24
|
+
@if $i == grid-vars.$columns {
|
|
25
|
+
$margins: 2;
|
|
26
|
+
}
|
|
23
27
|
|
|
24
28
|
// Calculate total width for spanning i columns:
|
|
25
29
|
// - i columns × column width
|
package/src/styles/_mixin.scss
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
@
|
|
2
|
-
@
|
|
1
|
+
@forward './mixins/disabled';
|
|
2
|
+
@forward './mixins/list';
|
|
@@ -181,15 +181,15 @@
|
|
|
181
181
|
--positive-050: var(--color-dap-green-050); // was --color-positive-050
|
|
182
182
|
--positive-700-default: var(--color-dap-green-700); // was --color-positive-700-default
|
|
183
183
|
|
|
184
|
-
/* Alias -
|
|
185
|
-
--
|
|
186
|
-
--
|
|
187
|
-
--
|
|
188
|
-
--
|
|
189
|
-
--
|
|
190
|
-
--
|
|
191
|
-
--
|
|
192
|
-
--
|
|
184
|
+
/* Alias - error */
|
|
185
|
+
--error-100: var(--color-dap-red-100); // ⚠ NEW VALUE (was #f5c2bf in --color-negative-100)
|
|
186
|
+
--error-200: var(--color-dap-red-200); // was --color-negative-200
|
|
187
|
+
--error-300: var(--color-dap-red-300); // was --color-negative-300
|
|
188
|
+
--error-500: var(--color-dap-red-500); // was --color-negative-500
|
|
189
|
+
--error-600: var(--color-dap-red-600); // was --color-negative-600
|
|
190
|
+
--error-700: var(--color-dap-red-700); // was --color-negative-700
|
|
191
|
+
--error-050: var(--color-dap-red-050); // was --color-negative-050
|
|
192
|
+
--error-400-default: var(--color-dap-red-400); // was --color-negative-400-default
|
|
193
193
|
|
|
194
194
|
/* Alias - warning */
|
|
195
195
|
--warning-100: var(--color-dap-orange-100); // was --color-warning-100
|
|
@@ -268,11 +268,11 @@
|
|
|
268
268
|
--background-background-info-strong: var(--primary-800); // was --color-background-info-strong
|
|
269
269
|
--background-background-warning: var(--warning-100); // was --color-background-warning
|
|
270
270
|
--background-background-warning-strong: var(--warning-600-default); // was --color-background-warning-strong
|
|
271
|
-
--background-background-
|
|
272
|
-
--background-background-
|
|
273
|
-
--background-background-
|
|
274
|
-
--background-background-
|
|
275
|
-
--background-background-
|
|
271
|
+
--background-background-error: var(--error-400-default); // was --color-negative-400-default
|
|
272
|
+
--background-background-error-hover: var(--error-500); // was --color-background-danger-strong-hover
|
|
273
|
+
--background-background-error-selected: var(--error-600); // was --color-negative-600
|
|
274
|
+
--background-background-error-secondary-hover: var(--error-050); // was --color-negative-050
|
|
275
|
+
--background-background-error-secondary-selected: var(--error-100);
|
|
276
276
|
--background-background-active-moderate: var(--primary-400); // was --color-background-active-moderate
|
|
277
277
|
--background-background-transparent-hover: rgba(255, 255, 255, 0.15);
|
|
278
278
|
--background-background-transparent-active: rgba(255, 255, 255, 0.3);
|
|
@@ -298,9 +298,9 @@
|
|
|
298
298
|
--border-border-action-secondary-hover: var(--brand-400); // was --color-border-action-secondary-hover
|
|
299
299
|
--border-border-positive: var(--positive-600); // was --color-positive-600
|
|
300
300
|
--border-border-warning-strong: var(--warning-600-default); // was --color-background-warning-strong
|
|
301
|
-
--border-border-
|
|
302
|
-
--border-border-
|
|
303
|
-
--border-border-
|
|
301
|
+
--border-border-error-weak: var(--error-100);
|
|
302
|
+
--border-border-error: var(--error-400-default); // was --color-negative-400-default
|
|
303
|
+
--border-border-error-hover: var(--error-500); // was --color-negative-500
|
|
304
304
|
--border-border-inverse: var(--secondary-050); // was --color-border-inverse
|
|
305
305
|
--border-border-field: var(--secondary-200); // was --color-border-disabled
|
|
306
306
|
--border-border-field-hover: var(--brand-600); // was --color-background-action-hover
|
|
@@ -320,7 +320,7 @@
|
|
|
320
320
|
--font-font-on-disabled: var(--secondary-800); // was --color-font-on-disabled
|
|
321
321
|
--font-font-positive: var(--positive-700-default); // was --color-font-success
|
|
322
322
|
--font-font-warning: var(--warning-700); // was --color-font-warning
|
|
323
|
-
--font-font-
|
|
323
|
+
--font-font-error: var(--error-500); // was --color-font-danger
|
|
324
324
|
--font-font-code: var(--primary-900); // was --color-font-code
|
|
325
325
|
--font-font-highlight: var(--color-dap-data-deep-fuchsia); // was --color-font-highlight
|
|
326
326
|
--font-font-placeholder: var(--secondary-500-net-4); // was --color-font-disabled
|
|
@@ -339,7 +339,7 @@
|
|
|
339
339
|
--icon-icon-positive: var(--positive-700-default); // was --color-icon-success
|
|
340
340
|
--icon-icon-warning-light: var(--warning-400); // was --color-warning-400
|
|
341
341
|
--icon-icon-warning: var(--warning-600-default); // was --color-icon-warning
|
|
342
|
-
--icon-icon-
|
|
342
|
+
--icon-icon-error: var(--error-500); // was --color-negative-500
|
|
343
343
|
--icon-icon-pause: var(--paused-600); // was --color-data-blue
|
|
344
344
|
--icon-icon-information-main: var(--secondary-500-net-4); // was --color-icon-information-main
|
|
345
345
|
--icon-icon-information-secondary: var(--primary-600); // was --color-icon-information-secondary
|
|
@@ -397,7 +397,7 @@
|
|
|
397
397
|
--status-bg-pending: var(--warning-100); // was --color-status-bg-pending
|
|
398
398
|
--status-bg-warning: var(--warning-200); // was --color-status-bg-warning
|
|
399
399
|
--status-bg-inactive: var(--background-background-tertiary-selected-weak);
|
|
400
|
-
--status-bg-failed: var(--background-background-
|
|
400
|
+
--status-bg-failed: var(--background-background-error-secondary-hover);
|
|
401
401
|
|
|
402
402
|
/* Semantic - Dark Buttons */
|
|
403
403
|
--dark-buttons-primary: var(--darks-400); // was --color-dark-buttons-primary
|
|
@@ -25,7 +25,12 @@ $scrollbar-thin-size: 4px;
|
|
|
25
25
|
|
|
26
26
|
@mixin firefox-scrollbar($size) {
|
|
27
27
|
@-moz-document url-prefix() {
|
|
28
|
-
|
|
28
|
+
@if $size == $scrollbar-thin-size {
|
|
29
|
+
scrollbar-width: thin;
|
|
30
|
+
} @else {
|
|
31
|
+
scrollbar-width: auto;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
scrollbar-color: var(--color-background-deselected-hover) var(--color-background-secondary);
|
|
30
35
|
}
|
|
31
36
|
}
|
|
@@ -57,8 +57,7 @@ $textarea-height-default: 120px;
|
|
|
57
57
|
|
|
58
58
|
&:has(.input:disabled) {
|
|
59
59
|
color: var(--color-background-deselected);
|
|
60
|
-
|
|
61
|
-
border-color: var(--color-border-disabled);
|
|
60
|
+
border-color: var(--border-border-disabled);
|
|
62
61
|
cursor: not-allowed;
|
|
63
62
|
|
|
64
63
|
.input {
|
|
@@ -82,26 +81,7 @@ $textarea-height-default: 120px;
|
|
|
82
81
|
|
|
83
82
|
&:disabled {
|
|
84
83
|
color: var(--color-background-deselected);
|
|
85
|
-
|
|
86
|
-
border-color: var(--color-border-disabled);
|
|
87
|
-
cursor: not-allowed;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
@mixin form-control-states {
|
|
92
|
-
&:hover:not(:disabled) {
|
|
93
|
-
border-color: var(--form-control-border-color, var(--color-background-action-hover));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
&:focus {
|
|
97
|
-
border-color: var(--form-control-border-color, var(--color-border-action-primary));
|
|
98
|
-
outline: none;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
&:disabled {
|
|
102
|
-
color: var(--color-background-deselected);
|
|
103
|
-
background: var(--color-background-secondary);
|
|
104
|
-
border-color: var(--color-border-disabled);
|
|
84
|
+
border-color: var(--border-border-disabled);
|
|
105
85
|
cursor: not-allowed;
|
|
106
86
|
}
|
|
107
87
|
}
|
|
@@ -129,8 +109,7 @@ $textarea-height-default: 120px;
|
|
|
129
109
|
|
|
130
110
|
&[data-disabled] {
|
|
131
111
|
color: var(--color-background-deselected);
|
|
132
|
-
|
|
133
|
-
border-color: var(--color-border-disabled);
|
|
112
|
+
border-color: var(--border-border-disabled);
|
|
134
113
|
|
|
135
114
|
&,
|
|
136
115
|
& * {
|
|
@@ -245,22 +224,6 @@ $textarea-height-default: 120px;
|
|
|
245
224
|
}
|
|
246
225
|
}
|
|
247
226
|
|
|
248
|
-
// Form control base styles
|
|
249
|
-
@mixin formControl {
|
|
250
|
-
@include base-input-container;
|
|
251
|
-
@include form-control-states;
|
|
252
|
-
cursor: pointer;
|
|
253
|
-
justify-content: space-between;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
@mixin formControlSmall {
|
|
257
|
-
max-height: $input-text-height-small;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
@mixin formControlDefault {
|
|
261
|
-
max-height: $input-text-height-default;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
227
|
// Select trigger styles
|
|
265
228
|
@mixin selectControl {
|
|
266
229
|
display: grid;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { page } from 'vitest/browser';
|
|
4
|
+
import { renderHook } from 'vitest-browser-react';
|
|
5
|
+
|
|
6
|
+
import { useBreakpoint, useResponsiveValue, withResponsiveProps } from './responsive';
|
|
7
|
+
|
|
8
|
+
const mockMatchMedia = (matches: boolean) => {
|
|
9
|
+
const listeners: Array<() => void> = [];
|
|
10
|
+
|
|
11
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
12
|
+
writable: true,
|
|
13
|
+
value: vi.fn().mockReturnValue({
|
|
14
|
+
matches,
|
|
15
|
+
addEventListener: (_: string, cb: () => void) => listeners.push(cb),
|
|
16
|
+
removeEventListener: (_: string, cb: () => void) => {
|
|
17
|
+
const idx = listeners.indexOf(cb);
|
|
18
|
+
|
|
19
|
+
if (idx >= 0) {
|
|
20
|
+
listeners.splice(idx, 1);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
triggerChange: () => listeners.forEach((cb) => cb()),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('useBreakpoint', () => {
|
|
32
|
+
it('should return lg when viewport >= 1440px', async () => {
|
|
33
|
+
mockMatchMedia(true);
|
|
34
|
+
const { result } = await renderHook(() => useBreakpoint());
|
|
35
|
+
|
|
36
|
+
expect(result.current).toBe('lg');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return md when viewport < 1440px', async () => {
|
|
40
|
+
mockMatchMedia(false);
|
|
41
|
+
const { result } = await renderHook(() => useBreakpoint());
|
|
42
|
+
|
|
43
|
+
expect(result.current).toBe('md');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('useResponsiveValue', () => {
|
|
48
|
+
it('should return a static value unchanged', async () => {
|
|
49
|
+
mockMatchMedia(true);
|
|
50
|
+
const { result } = await renderHook(() => useResponsiveValue('large'));
|
|
51
|
+
|
|
52
|
+
expect(result.current).toBe('large');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should resolve lg value on large screens', async () => {
|
|
56
|
+
mockMatchMedia(true);
|
|
57
|
+
const { result } = await renderHook(() => useResponsiveValue({ lg: 'large', md: 'small' }));
|
|
58
|
+
|
|
59
|
+
expect(result.current).toBe('large');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should resolve md value on small screens', async () => {
|
|
63
|
+
mockMatchMedia(false);
|
|
64
|
+
const { result } = await renderHook(() => useResponsiveValue({ lg: 'large', md: 'small' }));
|
|
65
|
+
|
|
66
|
+
expect(result.current).toBe('small');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('withResponsiveProps', () => {
|
|
71
|
+
const Base = ({ value }: { value?: string }) => createElement('span', null, value);
|
|
72
|
+
const Enhanced = withResponsiveProps(Base, ['value']);
|
|
73
|
+
|
|
74
|
+
it('should resolve responsive prop to lg value on large screens', async () => {
|
|
75
|
+
mockMatchMedia(true);
|
|
76
|
+
await page.render(createElement(Enhanced, { value: { lg: 'desktop', md: 'mobile' } }));
|
|
77
|
+
|
|
78
|
+
await expect.element(page.getByText('desktop')).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should resolve responsive prop to md value on small screens', async () => {
|
|
82
|
+
mockMatchMedia(false);
|
|
83
|
+
await page.render(createElement(Enhanced, { value: { lg: 'desktop', md: 'mobile' } }));
|
|
84
|
+
|
|
85
|
+
await expect.element(page.getByText('mobile')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should pass through static values unchanged', async () => {
|
|
89
|
+
mockMatchMedia(true);
|
|
90
|
+
await page.render(createElement(Enhanced, { value: 'static' }));
|
|
91
|
+
|
|
92
|
+
await expect.element(page.getByText('static')).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createElement, useSyncExternalStore, type FunctionComponent } from 'react';
|
|
2
|
+
|
|
3
|
+
import breakpointTokens from '../styles/_breakpoints.module.scss';
|
|
4
|
+
|
|
5
|
+
export const BREAKPOINT_LG = parseInt(breakpointTokens.breakpointLg, 10);
|
|
6
|
+
|
|
7
|
+
export const breakpoints = ['lg', 'md'] as const;
|
|
8
|
+
export type Breakpoint = (typeof breakpoints)[number];
|
|
9
|
+
|
|
10
|
+
export type Responsive<T> = Partial<Record<Breakpoint, T>>;
|
|
11
|
+
|
|
12
|
+
export type ResponsiveValue<T> = T | Responsive<T>;
|
|
13
|
+
|
|
14
|
+
export const isResponsiveValue = <T>(value: ResponsiveValue<T>): value is Responsive<T> =>
|
|
15
|
+
value !== null && typeof value === 'object' && breakpoints.some((bp) => bp in value);
|
|
16
|
+
|
|
17
|
+
export const resolveResponsiveValue = <T>(value: ResponsiveValue<T>, breakpoint: Breakpoint): T => {
|
|
18
|
+
if (!isResponsiveValue(value)) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (value[breakpoint] ?? value.lg ?? value.md) as T;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MEDIA_QUERY = `(min-width: ${String(BREAKPOINT_LG)}px)`;
|
|
26
|
+
|
|
27
|
+
const subscribe = (callback: () => void) => {
|
|
28
|
+
const mql = window.matchMedia(MEDIA_QUERY);
|
|
29
|
+
mql.addEventListener('change', callback);
|
|
30
|
+
return () => mql.removeEventListener('change', callback);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const getSnapshot = (): Breakpoint => (window.matchMedia(MEDIA_QUERY).matches ? 'lg' : 'md');
|
|
34
|
+
|
|
35
|
+
const getServerSnapshot = (): Breakpoint => 'lg';
|
|
36
|
+
|
|
37
|
+
export const useBreakpoint = (): Breakpoint =>
|
|
38
|
+
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves a `ResponsiveValue<T>` to `T` using the current breakpoint.
|
|
42
|
+
* Use for conditional rendering or JS logic that depends on the breakpoint.
|
|
43
|
+
*/
|
|
44
|
+
export const useResponsiveValue = <T>(value: ResponsiveValue<T>): T =>
|
|
45
|
+
resolveResponsiveValue(value, useBreakpoint());
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wraps a component so that the specified props accept `ResponsiveValue<T>`.
|
|
49
|
+
* The wrapper resolves each responsive prop to a plain value before rendering,
|
|
50
|
+
* so the wrapped component stays completely unaware of breakpoints.
|
|
51
|
+
*/
|
|
52
|
+
export function withResponsiveProps<Props, const Keys extends readonly (keyof Props)[]>(
|
|
53
|
+
Component: FunctionComponent<Props>,
|
|
54
|
+
responsiveKeys: Keys,
|
|
55
|
+
) {
|
|
56
|
+
type EnhancedProps = {
|
|
57
|
+
[P in keyof Props]: P extends Keys[number] ? ResponsiveValue<NonNullable<Props[P]>> : Props[P];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const Wrapper = (props: EnhancedProps) => {
|
|
61
|
+
const breakpoint = useBreakpoint();
|
|
62
|
+
const resolved = { ...props } as Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
for (const key of responsiveKeys) {
|
|
65
|
+
const k = String(key);
|
|
66
|
+
const value = resolved[k];
|
|
67
|
+
|
|
68
|
+
if (value !== undefined) {
|
|
69
|
+
resolved[k] = resolveResponsiveValue(value, breakpoint);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return createElement(Component as FunctionComponent<Record<string, unknown>>, resolved);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
Wrapper.displayName = `withResponsiveProps(${Component.displayName ?? Component.name})`;
|
|
77
|
+
|
|
78
|
+
return Wrapper;
|
|
79
|
+
}
|