@drivenets/design-system 0.7.3 → 0.9.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/index.cjs +5730 -4569
  3. package/dist/index.d.cts +1768 -1065
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +1768 -1065
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +5718 -4577
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.min.css +1 -1
  10. package/package.json +14 -14
  11. package/src/components/ds-autocomplete/ds-autocomplete.module.scss +1 -2
  12. package/src/components/ds-autocomplete/ds-autocomplete.tsx +13 -8
  13. package/src/components/ds-autocomplete/ds-autocomplete.types.ts +2 -1
  14. package/src/components/ds-button/versions/ds-button-new/ds-button-new.module.scss +25 -9
  15. package/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx +5 -5
  16. package/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts +11 -1
  17. package/src/components/ds-button/versions/ds-button-new/index.ts +4 -1
  18. package/src/components/ds-button-v3/ds-button-v3.module.scss +391 -0
  19. package/src/components/ds-button-v3/ds-button-v3.tsx +55 -0
  20. package/src/components/ds-button-v3/ds-button-v3.types.ts +49 -0
  21. package/src/components/ds-button-v3/index.ts +2 -0
  22. package/src/components/ds-comment-bubble/ds-comment-bubble.module.scss +6 -2
  23. package/src/components/ds-comment-bubble/ds-comment-bubble.tsx +22 -15
  24. package/src/components/ds-comment-bubble/ds-comment-bubble.types.ts +1 -0
  25. package/src/components/ds-comments-drawer/components/comments-filter-modal/comments-filter-modal.tsx +1 -1
  26. package/src/components/ds-comments-drawer/ds-comments-drawer.tsx +3 -9
  27. package/src/components/ds-date-input/ds-date-input.tsx +3 -4
  28. package/src/components/ds-date-input/ds-date-input.types.ts +3 -1
  29. package/src/components/ds-date-input/index.ts +10 -0
  30. package/src/components/ds-date-picker/components/calendar-table.module.scss +93 -0
  31. package/src/components/ds-date-picker/components/calendar-view-control/calendar-view-control.module.scss +48 -0
  32. package/src/components/ds-date-picker/components/calendar-view-control/calendar-view-control.tsx +20 -0
  33. package/src/components/ds-date-picker/components/calendar-view-control/index.ts +1 -0
  34. package/src/components/ds-date-picker/components/day-view/day-view.module.scss +12 -0
  35. package/src/components/ds-date-picker/components/day-view/day-view.tsx +45 -0
  36. package/src/components/ds-date-picker/components/day-view/index.ts +1 -0
  37. package/src/components/ds-date-picker/components/month-view/index.ts +1 -0
  38. package/src/components/ds-date-picker/components/month-view/month-view.module.scss +4 -0
  39. package/src/components/ds-date-picker/components/month-view/month-view.tsx +35 -0
  40. package/src/components/ds-date-picker/components/year-view/index.ts +1 -0
  41. package/src/components/ds-date-picker/components/year-view/year-view.module.scss +4 -0
  42. package/src/components/ds-date-picker/components/year-view/year-view.tsx +35 -0
  43. package/src/components/ds-date-picker/ds-date-picker.module.scss +29 -0
  44. package/src/components/ds-date-picker/ds-date-picker.tsx +211 -0
  45. package/src/components/ds-date-picker/ds-date-picker.types.ts +94 -0
  46. package/src/components/ds-date-picker/ds-date-picker.utils.ts +87 -0
  47. package/src/components/ds-date-picker/ds-date-picker.utils.unit.test.ts +226 -0
  48. package/src/components/ds-date-picker/index.ts +2 -0
  49. package/src/components/ds-date-range-picker/ds-date-range-picker.module.scss +20 -0
  50. package/src/components/ds-date-range-picker/ds-date-range-picker.tsx +101 -0
  51. package/src/components/ds-date-range-picker/ds-date-range-picker.types.ts +95 -0
  52. package/src/components/ds-date-range-picker/ds-date-range-picker.utils.ts +19 -0
  53. package/src/components/ds-date-range-picker/ds-date-range-picker.utils.unit.test.ts +48 -0
  54. package/src/components/ds-date-range-picker/index.ts +9 -0
  55. package/src/components/ds-drawer/ds-drawer.tsx +6 -9
  56. package/src/components/ds-drawer/ds-drawer.types.ts +4 -2
  57. package/src/components/ds-file-upload/ds-file-upload-api.types.ts +1 -0
  58. package/src/components/ds-file-upload/hooks/use-file-upload.ts +1 -1
  59. package/src/components/ds-file-upload/index.ts +6 -1
  60. package/src/components/ds-file-upload/utils/file-factory.ts +1 -0
  61. package/src/components/ds-file-upload/utils/file-factory.unit.test.ts +37 -0
  62. package/src/components/ds-form-control/ds-form-control.tsx +7 -3
  63. package/src/components/ds-key-value-pair/ds-key-value-pair.module.scss +72 -0
  64. package/src/components/ds-key-value-pair/ds-key-value-pair.tsx +47 -0
  65. package/src/components/ds-key-value-pair/ds-key-value-pair.types.ts +24 -0
  66. package/src/components/ds-key-value-pair/index.ts +3 -0
  67. package/src/components/ds-loader/ds-loader.tsx +1 -1
  68. package/src/components/ds-panel/ds-panel.module.scss +4 -1
  69. package/src/components/ds-panel/ds-panel.tsx +8 -3
  70. package/src/components/ds-panel/ds-panel.types.ts +8 -1
  71. package/src/components/ds-panel/index.ts +4 -1
  72. package/src/components/ds-select/ds-select.module.scss +4 -1
  73. package/src/components/ds-split-button/ds-split-button.module.scss +99 -0
  74. package/src/components/ds-split-button/ds-split-button.tsx +49 -0
  75. package/src/components/ds-split-button/ds-split-button.types.ts +25 -0
  76. package/src/components/ds-split-button/index.ts +10 -0
  77. package/src/components/ds-stack/ds-stack.module.scss +4 -0
  78. package/src/components/ds-stack/ds-stack.tsx +35 -0
  79. package/src/components/ds-stack/ds-stack.types.ts +19 -0
  80. package/src/components/ds-stack/index.ts +18 -0
  81. package/src/components/ds-status-badge/ds-status-badge.module.scss +1 -1
  82. package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.tsx +6 -4
  83. package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.module.scss +5 -8
  84. package/src/components/ds-table/ds-table.module.scss +6 -0
  85. package/src/components/ds-table/ds-table.tsx +3 -0
  86. package/src/components/ds-time-picker/components/time-item/index.ts +1 -0
  87. package/src/components/ds-time-picker/components/time-item/time-item.module.scss +37 -0
  88. package/src/components/ds-time-picker/components/time-item/time-item.tsx +26 -0
  89. package/src/components/ds-time-picker/components/time-item/time-item.types.ts +10 -0
  90. package/src/components/ds-time-picker/components/time-scroller/index.ts +2 -0
  91. package/src/components/ds-time-picker/components/time-scroller/time-scroller.module.scss +25 -0
  92. package/src/components/ds-time-picker/components/time-scroller/time-scroller.tsx +61 -0
  93. package/src/components/ds-time-picker/components/time-scroller/time-scroller.types.ts +17 -0
  94. package/src/components/ds-time-picker/components/time-scroller/time-scroller.utils.ts +44 -0
  95. package/src/components/ds-time-picker/ds-time-picker.module.scss +11 -0
  96. package/src/components/ds-time-picker/ds-time-picker.tsx +178 -0
  97. package/src/components/ds-time-picker/ds-time-picker.types.ts +54 -0
  98. package/src/components/ds-time-picker/ds-time-picker.utils.ts +176 -0
  99. package/src/components/ds-time-picker/ds-time-picker.utils.unit.test.ts +316 -0
  100. package/src/components/ds-time-picker/index.ts +3 -0
  101. package/src/components/ds-tooltip/ds-tooltip.module.scss +1 -7
  102. package/src/components/ds-tooltip/ds-tooltip.tsx +9 -9
  103. package/src/components/ds-tooltip/ds-tooltip.types.ts +7 -1
  104. package/src/index.ts +8 -0
  105. package/src/styles/_breakpoints.module.scss +5 -0
  106. package/src/styles/_responsive.scss +13 -0
  107. package/src/styles/_root_updated.scss +593 -0
  108. package/src/styles/_variables.scss +2 -0
  109. package/src/styles/shared/_button.scss +2 -0
  110. package/src/styles/shared/_dropdown.scss +4 -1
  111. package/src/styles/shared/_input.scss +3 -40
  112. package/src/utils/responsive.browser.test.ts +94 -0
  113. package/src/utils/responsive.ts +79 -0
  114. package/src/utils/responsive.unit.test.ts +54 -0
  115. package/src/utils/type-utils.ts +1 -0
  116. package/src/utils/use-controlled.browser.test.ts +79 -0
  117. package/src/utils/use-controlled.ts +18 -0
@@ -1,9 +1,12 @@
1
1
  import type * as Collapsible from '@radix-ui/react-collapsible';
2
2
 
3
+ import type { ResponsiveValue } from '../../utils/responsive';
4
+
3
5
  export type DsPanelVariant = 'docked' | 'floating';
4
6
 
5
- export type DsPanelProps = Omit<Collapsible.CollapsibleProps, 'onOpenChange'> & {
7
+ export type DsPanelBaseProps = Omit<Collapsible.CollapsibleProps, 'onOpenChange'> & {
6
8
  variant?: DsPanelVariant;
9
+ width?: number;
7
10
  draggable?: boolean;
8
11
  disablePadding?: boolean;
9
12
  slotProps?: {
@@ -12,6 +15,10 @@ export type DsPanelProps = Omit<Collapsible.CollapsibleProps, 'onOpenChange'> &
12
15
  onOpenChange?: (open: boolean) => void;
13
16
  };
14
17
 
18
+ export type DsPanelProps = Omit<DsPanelBaseProps, 'width'> & {
19
+ width?: ResponsiveValue<number>;
20
+ };
21
+
15
22
  export type DsPanelCollapseButtonProps = {
16
23
  onClick?: () => void;
17
24
  collapsed?: boolean;
@@ -1,2 +1,5 @@
1
- export { DsPanel } from './ds-panel';
1
+ import { withResponsiveProps } from '../../utils/responsive';
2
+ import { DsPanelBase } from './ds-panel';
3
+
4
+ export const DsPanel = withResponsiveProps(DsPanelBase, ['width']);
2
5
  export type { DsPanelProps, DsPanelVariant, DsPanelCollapseButtonProps } from './ds-panel.types';
@@ -53,8 +53,11 @@
53
53
  @include typography.ellipsis;
54
54
  grid-column: 1;
55
55
  grid-row: 1;
56
- color: var(--color-font-secondary);
57
56
  text-align: start;
57
+
58
+ .trigger:not([data-disabled]) & {
59
+ color: var(--color-font-secondary);
60
+ }
58
61
  }
59
62
 
60
63
  .multiSelectItemsContainer {
@@ -0,0 +1,99 @@
1
+ @use '../../styles/shared/button' as button;
2
+
3
+ $highlighted-z-index: 1;
4
+ $divider-z-index: $highlighted-z-index + 1;
5
+ $divider-width: 1px;
6
+ $border-width: 1px;
7
+
8
+ @mixin when-button-disabled {
9
+ .root:has(.actionButton:disabled) & {
10
+ @content;
11
+ }
12
+ }
13
+
14
+ @mixin when-select-disabled {
15
+ .root:has(.select[data-disabled]) & {
16
+ @content;
17
+ }
18
+ }
19
+
20
+ @mixin when-button-highlighted {
21
+ .root:has(.actionButton:not(:disabled):is(:hover, :focus-visible, :active, [data-selected='true'])) & {
22
+ @content;
23
+ }
24
+ }
25
+
26
+ @mixin when-select-highlighted {
27
+ .root:has(
28
+ .select:not([data-disabled]):is(:hover, :active, [data-state='open']),
29
+ .select:not([data-disabled]) :focus-visible
30
+ )
31
+ & {
32
+ @content;
33
+ }
34
+ }
35
+
36
+ .root {
37
+ display: inline-flex;
38
+
39
+ .actionButton {
40
+ border-top-right-radius: 0;
41
+ border-bottom-right-radius: 0;
42
+ }
43
+ }
44
+
45
+ .actionButton {
46
+ @include when-button-highlighted {
47
+ z-index: $highlighted-z-index;
48
+ transition: border-color button.$transition-duration-quick;
49
+ }
50
+
51
+ &:not(:disabled):is(:hover, :active, [data-selected='true']) {
52
+ border-right-color: var(--border-border-secondary-hover);
53
+ }
54
+
55
+ &:not(:disabled):focus-visible {
56
+ border-right-color: var(--border-border-inverse);
57
+ }
58
+ }
59
+
60
+ .select {
61
+ margin-left: -$divider-width;
62
+ border-top-left-radius: 0;
63
+ border-bottom-left-radius: 0;
64
+ }
65
+
66
+ .dividerAnchor {
67
+ position: relative;
68
+ }
69
+
70
+ .dividerWrapper {
71
+ position: absolute;
72
+ top: $border-width;
73
+ bottom: $border-width;
74
+ left: -$divider-width;
75
+ z-index: $divider-z-index;
76
+ width: $divider-width;
77
+ padding: var(--spacing-3xs) 0;
78
+ background-color: var(--background-background);
79
+
80
+ @include when-button-highlighted {
81
+ display: none;
82
+ }
83
+ @include when-select-highlighted {
84
+ display: none;
85
+ }
86
+ }
87
+
88
+ .divider {
89
+ background-color: var(--color-border-secondary);
90
+ width: $divider-width;
91
+ height: 100%;
92
+
93
+ @include when-button-disabled {
94
+ background-color: var(--border-border-disabled);
95
+ }
96
+ @include when-select-disabled {
97
+ background-color: var(--border-border-disabled);
98
+ }
99
+ }
@@ -0,0 +1,49 @@
1
+ import classNames from 'classnames';
2
+ import { DsSelect, type SelectSize } from '../ds-select';
3
+ import styles from './ds-split-button.module.scss';
4
+ import type { DsSplitButtonProps, SplitButtonSize } from './ds-split-button.types';
5
+ import { DsButtonV3 } from '../ds-button-v3';
6
+
7
+ const DsSplitButton = ({
8
+ ref,
9
+ className,
10
+ style,
11
+ size = 'medium',
12
+ disabled,
13
+ slotProps,
14
+ }: DsSplitButtonProps) => {
15
+ const { className: buttonClassName, disabled: buttonDisabled, ...buttonProps } = slotProps.button;
16
+
17
+ const { className: selectClassName, disabled: selectDisabled, ...selectProps } = slotProps.select;
18
+
19
+ return (
20
+ <div ref={ref} className={classNames(styles.root, className)} style={style}>
21
+ <DsButtonV3
22
+ {...buttonProps}
23
+ variant="secondary"
24
+ size={size}
25
+ disabled={buttonDisabled ?? disabled}
26
+ className={classNames(styles.actionButton, buttonClassName)}
27
+ />
28
+
29
+ <div className={styles.dividerAnchor}>
30
+ <div className={styles.dividerWrapper}>
31
+ <div className={styles.divider} />
32
+ </div>
33
+ </div>
34
+
35
+ <DsSelect
36
+ {...selectProps}
37
+ size={getSelectSize(size)}
38
+ disabled={selectDisabled ?? disabled}
39
+ className={classNames(styles.select, selectClassName)}
40
+ />
41
+ </div>
42
+ );
43
+ };
44
+
45
+ const getSelectSize = (size: SplitButtonSize): SelectSize => {
46
+ return size === 'medium' ? 'default' : 'small';
47
+ };
48
+
49
+ export default DsSplitButton;
@@ -0,0 +1,25 @@
1
+ import type { CSSProperties, Ref } from 'react';
2
+ import type { DsButtonV3Props } from '../ds-button-v3';
3
+ import type { DsSelectProps } from '../ds-select';
4
+ import type { DistributiveOmit } from '../../utils/type-utils';
5
+
6
+ export const splitButtonSizes = ['medium', 'small'] as const;
7
+ export type SplitButtonSize = (typeof splitButtonSizes)[number];
8
+
9
+ type ButtonSlotProps = Omit<DsButtonV3Props, 'variant' | 'color' | 'size'>;
10
+
11
+ type SelectSlotProps = DistributiveOmit<DsSelectProps, 'size'>;
12
+
13
+ export interface DsSplitButtonSlotProps {
14
+ button: ButtonSlotProps;
15
+ select: SelectSlotProps;
16
+ }
17
+
18
+ export interface DsSplitButtonProps {
19
+ ref?: Ref<HTMLDivElement>;
20
+ className?: string;
21
+ style?: CSSProperties;
22
+ size?: SplitButtonSize;
23
+ disabled?: boolean;
24
+ slotProps: DsSplitButtonSlotProps;
25
+ }
@@ -0,0 +1,10 @@
1
+ import type { ComponentProps } from 'react';
2
+ import { withResponsiveProps } from '../../utils/responsive';
3
+ import DsSplitButtonBase from './ds-split-button';
4
+ export { type DsSplitButtonSlotProps, splitButtonSizes } from './ds-split-button.types';
5
+
6
+ export const DsSplitButton = withResponsiveProps(DsSplitButtonBase, ['size']);
7
+
8
+ DsSplitButton.displayName = 'DsSplitButton';
9
+
10
+ export type DsSplitButtonProps = ComponentProps<typeof DsSplitButton>;
@@ -0,0 +1,4 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ }
@@ -0,0 +1,35 @@
1
+ import classNames from 'classnames';
2
+
3
+ import styles from './ds-stack.module.scss';
4
+ import type { DsStackProps } from './ds-stack.types';
5
+
6
+ export const DsStack = ({
7
+ direction,
8
+ gap,
9
+ alignItems,
10
+ justifyContent,
11
+ flex,
12
+ flexWrap,
13
+ width,
14
+ children,
15
+ className,
16
+ style,
17
+ ref,
18
+ }: DsStackProps) => {
19
+ const layoutStyle: React.CSSProperties = {
20
+ flexDirection: direction,
21
+ gap,
22
+ alignItems,
23
+ justifyContent,
24
+ flex,
25
+ flexWrap,
26
+ width,
27
+ ...style,
28
+ };
29
+
30
+ return (
31
+ <div ref={ref} className={classNames(styles.root, className)} style={layoutStyle}>
32
+ {children}
33
+ </div>
34
+ );
35
+ };
@@ -0,0 +1,19 @@
1
+ import type { Properties } from 'csstype';
2
+
3
+ type CSSProps = Properties<string | number>;
4
+ import type { CSSProperties, ReactNode, Ref } from 'react';
5
+
6
+ export interface DsStackProps {
7
+ direction?: CSSProps['flexDirection'];
8
+ gap?: CSSProps['gap'];
9
+ alignItems?: CSSProps['alignItems'];
10
+ justifyContent?: CSSProps['justifyContent'];
11
+ flex?: CSSProps['flex'];
12
+ flexWrap?: CSSProps['flexWrap'];
13
+ width?: CSSProps['width'];
14
+
15
+ children?: ReactNode;
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ ref?: Ref<HTMLDivElement>;
19
+ }
@@ -0,0 +1,18 @@
1
+ import type { ComponentProps } from 'react';
2
+
3
+ import { withResponsiveProps } from '../../utils/responsive';
4
+ import { DsStack as DsStackBase } from './ds-stack';
5
+
6
+ export const DsStack = withResponsiveProps(DsStackBase, [
7
+ 'direction',
8
+ 'gap',
9
+ 'alignItems',
10
+ 'justifyContent',
11
+ 'flex',
12
+ 'flexWrap',
13
+ 'width',
14
+ ]);
15
+
16
+ DsStack.displayName = 'DsStack';
17
+
18
+ export type DsStackProps = ComponentProps<typeof DsStack>;
@@ -74,6 +74,6 @@
74
74
  @include status-ghost(var(--color-background-danger-strong-hover), var(--color-status-bg-failed));
75
75
  }
76
76
 
77
- .label {
77
+ .label::first-letter {
78
78
  text-transform: capitalize;
79
79
  }
@@ -4,7 +4,7 @@ import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
4
4
  import styles from './ds-table-body-virtualized.module.scss';
5
5
  import { DsTableRowVirtualized } from '../ds-table-row-virtualized';
6
6
  import type { DsTableBodyVirtualizedProps } from './ds-table-body-virtualized.types';
7
- import { TableBody } from '../core-table';
7
+ import { TableBody, TableRow, TableCell } from '../core-table';
8
8
  import { EMPTY_TABLE_STATE_TEXT } from '../../utils/constants';
9
9
 
10
10
  export const DsTableBodyVirtualized = <TData,>({
@@ -16,8 +16,8 @@ export const DsTableBodyVirtualized = <TData,>({
16
16
  onScroll,
17
17
  rowSelection,
18
18
  }: DsTableBodyVirtualizedProps<TData>) => {
19
- const rowsMapRef = useRef<Map<string, HTMLTableRowElement>>(new Map());
20
- const rowHeightsMapRef = useRef<Map<string, number>>(new Map());
19
+ const rowsMapRef = useRef(new Map<string, HTMLTableRowElement>());
20
+ const rowHeightsMapRef = useRef(new Map<string, number>());
21
21
 
22
22
  const { rows } = table.getRowModel();
23
23
 
@@ -138,7 +138,9 @@ function DsTableBody<TData>({
138
138
  );
139
139
  })
140
140
  ) : (
141
- <div className={styles.emptyState}>{emptyState || EMPTY_TABLE_STATE_TEXT}</div>
141
+ <TableRow>
142
+ <TableCell className={styles.emptyState}>{emptyState || EMPTY_TABLE_STATE_TEXT}</TableCell>
143
+ </TableRow>
142
144
  )}
143
145
  </TableBody>
144
146
  );
@@ -58,18 +58,15 @@
58
58
  gap: 8px;
59
59
 
60
60
  > .actionButton {
61
+ display: flex;
62
+ flex-direction: column;
63
+ align-items: center;
64
+ justify-content: center;
65
+ gap: var(--spacing-3xs);
61
66
  border-right: 1px solid var(--neutral-6);
62
67
  width: 68px;
63
68
  height: 48px;
64
69
  border-radius: 0;
65
-
66
- > .actionButtonContent {
67
- display: flex;
68
- flex-direction: column;
69
- align-items: center;
70
- justify-content: center;
71
- gap: 4px;
72
- }
73
70
  }
74
71
 
75
72
  > .actionButton:first-child {
@@ -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 @@
1
+ export * from './time-item';
@@ -0,0 +1,37 @@
1
+ $time-item-height: 32px;
2
+ $time-item-width: 48px;
3
+
4
+ .container {
5
+ display: flex;
6
+ width: $time-item-width;
7
+ height: $time-item-height;
8
+ flex-shrink: 0;
9
+ flex-direction: column;
10
+ justify-content: center;
11
+ align-items: center;
12
+ border-radius: 8px;
13
+ background: transparent;
14
+ color: var(--color-font-main);
15
+
16
+ &.selected {
17
+ background: var(--color-background-selected);
18
+ color: var(--color-font-on-action);
19
+ }
20
+
21
+ &.disabled {
22
+ color: var(--color-font-disabled);
23
+ pointer-events: none;
24
+ }
25
+
26
+ &:not(.selected, .disabled) {
27
+ &:hover {
28
+ border: 1px solid var(--color-border);
29
+ background: var(--color-background-action-hover-weak);
30
+ }
31
+
32
+ &:focus-visible {
33
+ border: 2px solid var(--color-border-action-secondary);
34
+ background: var(--color-background-action-hover-weak);
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,26 @@
1
+ import classNames from 'classnames';
2
+ import type { TimeItemProps } from './time-item.types';
3
+ import styles from './time-item.module.scss';
4
+ import { DsTypography } from '../../../ds-typography';
5
+
6
+ export const TimeItem = ({ ref, className, selected, disabled, onClick, label }: TimeItemProps) => {
7
+ return (
8
+ <button
9
+ ref={ref}
10
+ type="button"
11
+ role="option"
12
+ aria-selected={selected}
13
+ aria-disabled={disabled || undefined}
14
+ disabled={disabled}
15
+ className={classNames(
16
+ styles.container,
17
+ selected && styles.selected,
18
+ disabled && styles.disabled,
19
+ className,
20
+ )}
21
+ onClick={onClick}
22
+ >
23
+ <DsTypography variant="body-md-md">{label}</DsTypography>
24
+ </button>
25
+ );
26
+ };
@@ -0,0 +1,10 @@
1
+ import type { RefObject } from 'react';
2
+
3
+ export interface TimeItemProps {
4
+ ref?: RefObject<HTMLButtonElement | null>;
5
+ className?: string;
6
+ label: string;
7
+ selected: boolean;
8
+ disabled?: boolean;
9
+ onClick: () => void;
10
+ }
@@ -0,0 +1,2 @@
1
+ export type { TimePeriod, TimeScrollerProps } from './time-scroller.types';
2
+ export * from './time-scroller';
@@ -0,0 +1,25 @@
1
+ $column-width: 56px;
2
+ $column-max-height: 280px;
3
+
4
+ .container {
5
+ display: flex;
6
+ padding: var(--spacing-xs);
7
+ background: var(--color-background);
8
+ box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.22);
9
+ }
10
+
11
+ .column {
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ width: $column-width;
16
+ gap: var(--spacing-xs);
17
+ max-height: $column-max-height;
18
+ overflow-y: auto;
19
+ scroll-snap-type: y mandatory;
20
+
21
+ scrollbar-width: none;
22
+ &::-webkit-scrollbar {
23
+ display: none;
24
+ }
25
+ }
@@ -0,0 +1,61 @@
1
+ import classNames from 'classnames';
2
+ import { TimeItem } from '../time-item';
3
+ import type { TimePeriod, TimeScrollerProps } from './time-scroller.types';
4
+ import styles from './time-scroller.module.scss';
5
+ import { useScrollToSelected } from './time-scroller.utils';
6
+
7
+ /**
8
+ * In AM/PM mode, hours start from 12 and then go from to 1 to 11.
9
+ * e.g. 12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
10
+ */
11
+ const HOURS = [12, ...Array.from({ length: 11 }, (_, i) => i + 1)];
12
+
13
+ const MINUTES = Array.from({ length: 60 }, (_, i) => i);
14
+ const PERIODS: TimePeriod[] = ['AM', 'PM'];
15
+
16
+ export const TimeScroller = ({ open, slotProps: { hour, minute, period }, className }: TimeScrollerProps) => {
17
+ const hourScroll = useScrollToSelected(hour.value, open);
18
+ const minuteScroll = useScrollToSelected(minute.value, open);
19
+
20
+ return (
21
+ <div className={classNames(styles.container, className)}>
22
+ <div ref={hourScroll.columnRef} className={styles.column} role="listbox" aria-label="Hour">
23
+ {HOURS.map((h) => (
24
+ <TimeItem
25
+ key={h}
26
+ ref={h === hour.value ? hourScroll.selectedRef : undefined}
27
+ label={String(h).padStart(2, '0')}
28
+ selected={h === hour.value}
29
+ disabled={hour.isDisabled?.(h)}
30
+ onClick={() => hour.onChange?.(h)}
31
+ />
32
+ ))}
33
+ </div>
34
+
35
+ <div ref={minuteScroll.columnRef} className={styles.column} role="listbox" aria-label="Minute">
36
+ {MINUTES.map((m) => (
37
+ <TimeItem
38
+ key={m}
39
+ ref={m === minute.value ? minuteScroll.selectedRef : undefined}
40
+ label={String(m).padStart(2, '0')}
41
+ selected={m === minute.value}
42
+ disabled={minute.isDisabled?.(m)}
43
+ onClick={() => minute.onChange?.(m)}
44
+ />
45
+ ))}
46
+ </div>
47
+
48
+ <div className={styles.column} role="listbox" aria-label="AM/PM">
49
+ {PERIODS.map((p) => (
50
+ <TimeItem
51
+ key={p}
52
+ label={p}
53
+ selected={p === period.value}
54
+ disabled={period.isDisabled?.(p)}
55
+ onClick={() => period.onChange?.(p)}
56
+ />
57
+ ))}
58
+ </div>
59
+ </div>
60
+ );
61
+ };
@@ -0,0 +1,17 @@
1
+ export type TimePeriod = 'AM' | 'PM';
2
+
3
+ export interface TimeScrollerProps {
4
+ className?: string;
5
+ open: boolean;
6
+ slotProps: {
7
+ hour: TimeScrollerSlotProps<number>;
8
+ minute: TimeScrollerSlotProps<number>;
9
+ period: TimeScrollerSlotProps<TimePeriod>;
10
+ };
11
+ }
12
+
13
+ export interface TimeScrollerSlotProps<TValue> {
14
+ value?: TValue;
15
+ onChange?: (value: TValue) => void;
16
+ isDisabled?: (value: TValue) => boolean;
17
+ }
@@ -0,0 +1,44 @@
1
+ import { useLayoutEffect, useEffect, useRef } from 'react';
2
+
3
+ export const useScrollToSelected = (value: unknown, open: boolean) => {
4
+ const columnRef = useRef<HTMLDivElement | null>(null);
5
+ const selectedRef = useRef<HTMLButtonElement | null>(null);
6
+
7
+ useLayoutEffect(() => {
8
+ const column = columnRef.current;
9
+ const selected = selectedRef.current;
10
+
11
+ if (!column || !selected) {
12
+ return;
13
+ }
14
+
15
+ scrollToCenter(column, selected, 'smooth');
16
+ }, [value]);
17
+
18
+ useEffect(() => {
19
+ if (!open) {
20
+ return;
21
+ }
22
+
23
+ // Ark UI removes `display: none` from Popover.Content after React's commit phase,
24
+ // so layout measurements are only valid after the browser has painted.
25
+ requestAnimationFrame(() => {
26
+ const column = columnRef.current;
27
+ const selected = selectedRef.current;
28
+
29
+ if (!column || !selected) {
30
+ return;
31
+ }
32
+
33
+ scrollToCenter(column, selected, 'instant');
34
+ });
35
+ }, [open]);
36
+
37
+ return { columnRef, selectedRef };
38
+ };
39
+
40
+ const scrollToCenter = (column: HTMLDivElement, selected: HTMLButtonElement, behavior: ScrollBehavior) => {
41
+ const columnCenter = column.offsetHeight / 2;
42
+ const itemCenter = selected.offsetTop + selected.offsetHeight / 2;
43
+ column.scrollTo({ top: itemCenter - columnCenter, behavior });
44
+ };
@@ -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
+ }