@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.cjs +1567 -1077
  3. package/dist/index.d.cts +857 -135
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +857 -135
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1555 -1079
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.min.css +1 -1
  10. package/package.json +19 -17
  11. package/src/components/ds-autocomplete/ds-autocomplete.module.scss +0 -1
  12. package/src/components/ds-button/versions/ds-button-new/ds-button-new.module.scss +25 -9
  13. package/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx +5 -5
  14. package/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts +11 -1
  15. package/src/components/ds-button/versions/ds-button-new/index.ts +4 -1
  16. package/src/components/ds-button-v3/ds-button-v3.module.scss +391 -0
  17. package/src/components/ds-button-v3/ds-button-v3.tsx +55 -0
  18. package/src/components/ds-button-v3/ds-button-v3.types.ts +58 -0
  19. package/src/components/ds-button-v3/index.ts +6 -0
  20. package/src/components/ds-comment-card/ds-comment-card.module.scss +2 -2
  21. package/src/components/ds-comments-drawer/components/comments-filter-modal/comments-filter-modal.tsx +1 -1
  22. package/src/components/ds-comments-drawer/ds-comments-drawer.tsx +3 -9
  23. package/src/components/ds-date-picker/ds-date-picker.module.scss +10 -0
  24. package/src/components/ds-date-picker/ds-date-picker.tsx +4 -2
  25. package/src/components/ds-date-range-picker/ds-date-range-picker.tsx +2 -2
  26. package/src/components/ds-drawer/ds-drawer.tsx +6 -9
  27. package/src/components/ds-drawer/ds-drawer.types.ts +4 -2
  28. package/src/components/ds-form-control/ds-form-control.tsx +2 -3
  29. package/src/components/ds-panel/ds-panel.module.scss +4 -1
  30. package/src/components/ds-panel/ds-panel.tsx +8 -3
  31. package/src/components/ds-panel/ds-panel.types.ts +8 -1
  32. package/src/components/ds-panel/index.ts +4 -1
  33. package/src/components/ds-select/ds-select.module.scss +4 -1
  34. package/src/components/ds-split-button/ds-split-button.module.scss +99 -0
  35. package/src/components/ds-split-button/ds-split-button.tsx +49 -0
  36. package/src/components/ds-split-button/ds-split-button.types.ts +25 -0
  37. package/src/components/ds-split-button/index.ts +10 -0
  38. package/src/components/ds-stack/ds-stack.module.scss +4 -0
  39. package/src/components/ds-stack/ds-stack.tsx +35 -0
  40. package/src/components/ds-stack/ds-stack.types.ts +19 -0
  41. package/src/components/ds-stack/index.ts +18 -0
  42. package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.tsx +2 -2
  43. package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.module.scss +5 -8
  44. package/src/components/ds-table/components/ds-table-cell/ds-table-cell.tsx +7 -4
  45. package/src/components/ds-table/components/ds-table-cell/ds-table-cell.types.ts +6 -0
  46. package/src/components/ds-table/ds-table.module.scss +6 -0
  47. package/src/components/ds-table/ds-table.tsx +3 -0
  48. package/src/components/ds-time-picker/ds-time-picker.module.scss +11 -0
  49. package/src/components/ds-time-picker/ds-time-picker.tsx +32 -12
  50. package/src/components/ds-time-picker/ds-time-picker.types.ts +6 -0
  51. package/src/components/ds-tree/ds-tree.types.ts +2 -2
  52. package/src/index.ts +4 -0
  53. package/src/styles/_breakpoints.module.scss +5 -0
  54. package/src/styles/_grid.scss +5 -1
  55. package/src/styles/_mixin.scss +2 -2
  56. package/src/styles/_responsive.scss +13 -0
  57. package/src/styles/_root_updated.scss +20 -20
  58. package/src/styles/_scrollbars.scss +6 -1
  59. package/src/styles/_variables.scss +2 -0
  60. package/src/styles/shared/_button.scss +2 -0
  61. package/src/styles/shared/_input.scss +3 -40
  62. package/src/utils/responsive.browser.test.ts +94 -0
  63. package/src/utils/responsive.ts +79 -0
  64. package/src/utils/responsive.unit.test.ts +54 -0
  65. package/src/utils/type-utils.ts +1 -0
  66. package/src/utils/use-controlled.browser.test.ts +6 -6
  67. 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
- if (primaryRowActions.length || secondaryRowActions.length) {
23
- const hasSecondaryRowActions = secondaryRowActions.some((action) => !action.disabled?.(row.original));
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
- {primaryRowActions.map((action, i) => {
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
- {secondaryRowActions.map((action, i) => {
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
  >
@@ -0,0 +1,11 @@
1
+ .root {
2
+ &:where(:hover, :focus-within) {
3
+ .clearTrigger {
4
+ visibility: visible;
5
+ }
6
+ }
7
+ }
8
+
9
+ .clearTrigger {
10
+ visibility: hidden;
11
+ }
@@ -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
- <Popover.Trigger asChild>
130
- <DsButton
131
- design="v1.2"
132
- size="tiny"
133
- buttonType="tertiary"
134
- disabled={disabled || readOnly}
135
- aria-label={locale?.openLabel ?? 'Open time picker'}
136
- >
137
- <DsIcon icon="schedule" variant="outlined" size="tiny" />
138
- </DsButton>
139
- </Popover.Trigger>
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';
@@ -0,0 +1,5 @@
1
+ @use 'variables' as var;
2
+
3
+ :export {
4
+ breakpointLg: #{var.$breakpoint-lg};
5
+ }
@@ -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: if($i == grid-vars.$columns, 2, 1);
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
@@ -1,2 +1,2 @@
1
- @import './mixins/disabled';
2
- @import './mixins/list';
1
+ @forward './mixins/disabled';
2
+ @forward './mixins/list';
@@ -0,0 +1,13 @@
1
+ @use 'variables' as var;
2
+
3
+ @mixin lg {
4
+ @media (min-width: #{var.$breakpoint-lg}) {
5
+ @content;
6
+ }
7
+ }
8
+
9
+ @mixin md {
10
+ @media (max-width: #{var.$breakpoint-lg - 1}) {
11
+ @content;
12
+ }
13
+ }
@@ -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 - negative */
185
- --negative-100: var(--color-dap-red-100); // ⚠ NEW VALUE (was #f5c2bf in --color-negative-100)
186
- --negative-200: var(--color-dap-red-200); // was --color-negative-200
187
- --negative-300: var(--color-dap-red-300); // was --color-negative-300
188
- --negative-500: var(--color-dap-red-500); // was --color-negative-500
189
- --negative-600: var(--color-dap-red-600); // was --color-negative-600
190
- --negative-700: var(--color-dap-red-700); // was --color-negative-700
191
- --negative-050: var(--color-dap-red-050); // was --color-negative-050
192
- --negative-400-default: var(--color-dap-red-400); // was --color-negative-400-default
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-negative: var(--negative-400-default); // was --color-negative-400-default
272
- --background-background-negative-hover: var(--negative-500); // was --color-background-danger-strong-hover
273
- --background-background-negative-selected: var(--negative-600); // was --color-negative-600
274
- --background-background-negative-secondary-hover: var(--negative-050); // was --color-negative-050
275
- --background-background-negative-secondary-selected: var(--negative-100);
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-negative-weak: var(--negative-100);
302
- --border-border-negative: var(--negative-400-default); // was --color-negative-400-default
303
- --border-border-negative-hover: var(--negative-500); // was --color-negative-500
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-negative: var(--negative-500); // was --color-font-danger
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-negative: var(--negative-500); // was --color-negative-500
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-negative-secondary-hover);
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
- scrollbar-width: if($size == $scrollbar-thin-size, thin, auto);
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
  }
@@ -42,3 +42,5 @@ $line-height-action-small: 20px;
42
42
  $sidebar-spacing: 10px;
43
43
  $sidebar-width: 64px;
44
44
  $sidebar-width-expanded: 256px;
45
+
46
+ $breakpoint-lg: 1440px;
@@ -0,0 +1,2 @@
1
+ $transition-duration-default: 0.3s; // it looks a bit better with 0.3 than 0.2
2
+ $transition-duration-quick: 0.15s;
@@ -57,8 +57,7 @@ $textarea-height-default: 120px;
57
57
 
58
58
  &:has(.input:disabled) {
59
59
  color: var(--color-background-deselected);
60
- background: var(--color-background-secondary);
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
- background: var(--color-background-secondary);
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
- background: var(--color-background-secondary);
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
+ }