@eturnity/eturnity_reusable_components 9.28.3-panel-placement.0 → 9.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eturnity/eturnity_reusable_components",
3
- "version": "9.28.3-panel-placement.0",
3
+ "version": "9.31.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -1,11 +1,11 @@
1
1
  <svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <path transform="rotate(90 2.80078 4.06415)" fill="white" d="M2.8008 4.0642h8v1.6H2.8008Z" />
3
- <path transform="rotate(-180 7.00195 3.8642)" fill="white" d="M7.002 3.8642h4v1.6H7.002Z" />
4
- <path d="M8.5 3.07846L6.00098 0.935599L6.00098 5.22131L8.5 3.07846Z" fill="white" />
5
- <path transform="rotate(-180 11.002 13.8643)" fill="white" d="M11.002 13.8643h8v1.6H11.002Z" />
6
- <path fill-rule="evenodd" clip-rule="evenodd" d="M2 4.06409C2.55228 4.06409 3 3.61637 3 3.06409C3 2.5118 2.55228 2.06409 2 2.06409C1.44772 2.06409 1 2.5118 1 3.06409C1 3.61637 1.44772 4.06409 2 4.06409ZM2 5.06409C3.10457 5.06409 4 4.16866 4 3.06409C4 1.95952 3.10457 1.06409 2 1.06409C0.895431 1.06409 0 1.95952 0 3.06409C0 4.16866 0.895431 5.06409 2 5.06409Z" fill="white" />
7
- <path transform="rotate(90 12.7988 4.06433)" fill="white" d="M12.7988 4.0643h8v1.6H12.7988Z" />
8
- <path fill-rule="evenodd" clip-rule="evenodd" d="M12.002 4.06421C12.5542 4.06421 13.002 3.61649 13.002 3.06421C13.002 2.51192 12.5542 2.06421 12.002 2.06421C11.4497 2.06421 11.002 2.51192 11.002 3.06421C11.002 3.61649 11.4497 4.06421 12.002 4.06421ZM12.002 5.06421C13.1065 5.06421 14.002 4.16878 14.002 3.06421C14.002 1.95964 13.1065 1.06421 12.002 1.06421C10.8974 1.06421 10.002 1.95964 10.002 3.06421C10.002 4.16878 10.8974 5.06421 12.002 5.06421Z" fill="white" />
9
- <path fill-rule="evenodd" clip-rule="evenodd" d="M12.002 14.0643C12.5542 14.0643 13.002 13.6166 13.002 13.0643C13.002 12.512 12.5542 12.0643 12.002 12.0643C11.4497 12.0643 11.002 12.512 11.002 13.0643C11.002 13.6166 11.4497 14.0643 12.002 14.0643ZM12.002 15.0643C13.1065 15.0643 14.002 14.1688 14.002 13.0643C14.002 11.9597 13.1065 11.0643 12.002 11.0643C10.8974 11.0643 10.002 11.9597 10.002 13.0643C10.002 14.1688 10.8974 15.0643 12.002 15.0643Z" fill="white" />
10
- <path fill-rule="evenodd" clip-rule="evenodd" d="M2 14.0644C2.55228 14.0644 3 13.6167 3 13.0644C3 12.5121 2.55228 12.0644 2 12.0644C1.44772 12.0644 1 12.5121 1 13.0644C1 13.6167 1.44772 14.0644 2 14.0644ZM2 15.0644C3.10457 15.0644 4 14.169 4 13.0644C4 11.9598 3.10457 11.0644 2 11.0644C0.895431 11.0644 0 11.9598 0 13.0644C0 14.169 0.895431 15.0644 2 15.0644Z" fill="white" />
11
- </svg>
2
+ <rect x="2.80078" y="4.06415" width="8" height="1.6" transform="rotate(90 2.80078 4.06415)" fill="white"/>
3
+ <rect x="7.00195" y="3.8642" width="4" height="1.6" transform="rotate(-180 7.00195 3.8642)" fill="white"/>
4
+ <path d="M8.5 3.07846L6.00098 0.935599L6.00098 5.22131L8.5 3.07846Z" fill="white"/>
5
+ <rect x="11.002" y="13.8643" width="8" height="1.6" transform="rotate(-180 11.002 13.8643)" fill="white"/>
6
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M2 4.06409C2.55228 4.06409 3 3.61637 3 3.06409C3 2.5118 2.55228 2.06409 2 2.06409C1.44772 2.06409 1 2.5118 1 3.06409C1 3.61637 1.44772 4.06409 2 4.06409ZM2 5.06409C3.10457 5.06409 4 4.16866 4 3.06409C4 1.95952 3.10457 1.06409 2 1.06409C0.895431 1.06409 0 1.95952 0 3.06409C0 4.16866 0.895431 5.06409 2 5.06409Z" fill="white"/>
7
+ <rect x="12.7988" y="4.06433" width="8" height="1.6" transform="rotate(90 12.7988 4.06433)" fill="white"/>
8
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M12.002 4.06421C12.5542 4.06421 13.002 3.61649 13.002 3.06421C13.002 2.51192 12.5542 2.06421 12.002 2.06421C11.4497 2.06421 11.002 2.51192 11.002 3.06421C11.002 3.61649 11.4497 4.06421 12.002 4.06421ZM12.002 5.06421C13.1065 5.06421 14.002 4.16878 14.002 3.06421C14.002 1.95964 13.1065 1.06421 12.002 1.06421C10.8974 1.06421 10.002 1.95964 10.002 3.06421C10.002 4.16878 10.8974 5.06421 12.002 5.06421Z" fill="white"/>
9
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M12.002 14.0643C12.5542 14.0643 13.002 13.6166 13.002 13.0643C13.002 12.512 12.5542 12.0643 12.002 12.0643C11.4497 12.0643 11.002 12.512 11.002 13.0643C11.002 13.6166 11.4497 14.0643 12.002 14.0643ZM12.002 15.0643C13.1065 15.0643 14.002 14.1688 14.002 13.0643C14.002 11.9597 13.1065 11.0643 12.002 11.0643C10.8974 11.0643 10.002 11.9597 10.002 13.0643C10.002 14.1688 10.8974 15.0643 12.002 15.0643Z" fill="white"/>
10
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M2 14.0644C2.55228 14.0644 3 13.6167 3 13.0644C3 12.5121 2.55228 12.0644 2 12.0644C1.44772 12.0644 1 12.5121 1 13.0644C1 13.6167 1.44772 14.0644 2 14.0644ZM2 15.0644C3.10457 15.0644 4 14.169 4 13.0644C4 11.9598 3.10457 11.0644 2 11.0644C0.895431 11.0644 0 11.9598 0 13.0644C0 14.169 0.895431 15.0644 2 15.0644Z" fill="white"/>
11
+ </svg>
@@ -157,46 +157,6 @@ describe('ButtonIcon.vue', () => {
157
157
  }
158
158
  })
159
159
 
160
- it('replaces the icon with a spinner while loading', async () => {
161
- expect(wrapper.find('[data-test-id="spinner_container"]').exists()).toBe(
162
- false
163
- )
164
- expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(true)
165
-
166
- await wrapper.setProps({ isLoading: true })
167
- expect(wrapper.find('[data-test-id="spinner_container"]').exists()).toBe(
168
- true
169
- )
170
- expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(false)
171
-
172
- await wrapper.setProps({ isLoading: false })
173
- expect(wrapper.find('[data-test-id="spinner_container"]').exists()).toBe(
174
- false
175
- )
176
- expect(wrapper.findComponent({ name: 'Icon' }).props('name')).toBe(
177
- 'settings'
178
- )
179
- })
180
-
181
- it('keeps rendering the text while loading', async () => {
182
- await wrapper.setProps({ isLoading: true })
183
- expect(wrapper.find('[data-test-id="button-icon-text"]').text()).toBe(
184
- 'Click Me'
185
- )
186
- })
187
-
188
- it('renders a white spinner on types with white button text', async () => {
189
- await wrapper.setProps({ isLoading: true, type: 'primary' })
190
- expect(wrapper.findComponent({ name: 'NewSpinner' }).props('isWhite')).toBe(
191
- true
192
- )
193
-
194
- await wrapper.setProps({ type: 'secondary' })
195
- expect(wrapper.findComponent({ name: 'NewSpinner' }).props('isWhite')).toBe(
196
- false
197
- )
198
- })
199
-
200
160
  it('renders caret based on showCaret prop', async () => {
201
161
  await wrapper.setProps({ showCaret: true })
202
162
  let caret = wrapper.find('[data-test-id="button-icon-caret"]')
@@ -28,9 +28,7 @@
28
28
  :type="type"
29
29
  :variant="variant"
30
30
  >
31
- <RCSpinner v-if="isLoading" :is-white="isSpinnerWhite" size="tiny" />
32
31
  <Icon
33
- v-else
34
32
  :color="getIconColor"
35
33
  :disable-hover="disableIconHover"
36
34
  :fill-type="fillType"
@@ -85,11 +83,9 @@
85
83
  // iconName="external_icon"
86
84
  // fontColor="white"
87
85
  // fontSize="12px"
88
- // :isLoading="false" // swaps the icon for a spinner
89
86
  // />
90
87
 
91
88
  import Icon from '../../icon'
92
- import RCSpinner from '../../spinner'
93
89
  import styled from 'vue3-styled-components'
94
90
  import theme from '../../../assets/theme'
95
91
 
@@ -423,7 +419,6 @@
423
419
  ButtonWrapper,
424
420
  IconContainer,
425
421
  Icon,
426
- RCSpinner,
427
422
  NumberContainer,
428
423
  NumberText,
429
424
  CountBadge,
@@ -546,24 +541,11 @@
546
541
  default: false,
547
542
  type: Boolean,
548
543
  },
549
- isLoading: {
550
- required: false,
551
- default: false,
552
- type: Boolean,
553
- },
554
544
  },
555
545
  computed: {
556
546
  theme() {
557
547
  return theme
558
548
  },
559
- // The spinner only offers white/purple, so pick whichever contrasts with
560
- // the button background the same way the label already does.
561
- isSpinnerWhite() {
562
- return (
563
- this.theme.mainButton[this.appTheme][this.type][this.variant].default
564
- .textColor === this.theme.colors.white
565
- )
566
- },
567
549
  getIconColor() {
568
550
  if (this.iconColor) return this.iconColor
569
551
  if (this.isDisabled) {
@@ -484,7 +484,6 @@
484
484
  display: flex;
485
485
  gap: 10px;
486
486
  align-items: center;
487
- padding: ${(props) => (props.isInteractive ? '0 4px' : '0')};
488
487
  cursor: ${(props) => (props.isInteractive ? 'ew-resize' : 'auto')};
489
488
  border: ${(props) =>
490
489
  props.alignItems == 'vertical'
@@ -730,11 +729,6 @@
730
729
  required: false,
731
730
  default: true,
732
731
  },
733
- undefinedState: {
734
- type: Boolean,
735
- required: false,
736
- default: false,
737
- },
738
732
  allowNegative: {
739
733
  type: Boolean,
740
734
  required: false,
@@ -850,7 +844,6 @@
850
844
  },
851
845
  computed: {
852
846
  displayedPlaceholder() {
853
- if (this.undefinedState) return '-'
854
847
  if (this.placeholder) return this.placeholder
855
848
  if (this.defaultNumber)
856
849
  return `${this.defaultNumber}${this.hasSpaceBetweenUnit ? ' ' : ''}${
@@ -1128,9 +1121,6 @@
1128
1121
  }
1129
1122
  },
1130
1123
  formatWithCurrency(value) {
1131
- if (this.undefinedState && !this.isFocused) {
1132
- return '-'
1133
- }
1134
1124
  let adjustedMinValue =
1135
1125
  value || value === 0
1136
1126
  ? value
@@ -31,16 +31,15 @@
31
31
  :border-color="borderColor"
32
32
  :color-mode="colorMode"
33
33
  :data-id="item.hasOwnProperty('dataId') ? item.dataId : ''"
34
- :disabled="!!item.disabled"
35
34
  :height="height"
36
35
  :inactive-border-color="inactiveBorderColor"
37
36
  :inactive-color="inactiveColor"
38
- :is-active="!undefinedState && value == item.value"
37
+ :is-active="value == item.value"
39
38
  :primary-color="primaryColor"
40
39
  :secondary-color="secondaryColor"
41
40
  :size="size"
42
41
  :width="item.width || 'auto'"
43
- @click="selectItem(item)"
42
+ @click="selectItem(item.value)"
44
43
  >
45
44
  <OptionIconContainer v-if="item.icon">
46
45
  <RCIcon
@@ -146,7 +145,6 @@
146
145
  height: String,
147
146
  borderColor: String,
148
147
  inactiveBorderColor: String,
149
- disabled: Boolean,
150
148
  }
151
149
  const SwitchOption = styled('div', optionAttrs)`
152
150
  color: ${(props) =>
@@ -169,19 +167,17 @@
169
167
  props.height
170
168
  ? props.height
171
169
  : props.size === 'medium'
172
- ? '24px'
173
- : props.size === 'small'
174
- ? '16px'
175
- : '24px'};
170
+ ? '24px'
171
+ : props.size === 'small'
172
+ ? '16px'
173
+ : '24px'};
176
174
  padding: 0 10px;
177
175
  margin-right: -1px;
178
176
  transition: all 0.1s ease-in-out;
179
177
  overflow: hidden;
180
178
  gap: 5px;
181
- opacity: ${(props) => (props.disabled ? 0.4 : 1)};
182
- pointer-events: ${(props) => (props.disabled ? 'none' : 'auto')};
183
179
  & :hover {
184
- cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
180
+ cursor: pointer;
185
181
  }
186
182
  &:first-child {
187
183
  border-radius: 4px 0 0 4px;
@@ -268,11 +264,6 @@
268
264
  default: false,
269
265
  type: Boolean,
270
266
  },
271
- undefinedState: {
272
- required: false,
273
- default: false,
274
- type: Boolean,
275
- },
276
267
  },
277
268
  data() {
278
269
  return {
@@ -291,19 +282,19 @@
291
282
  this.borderColor = this.secondaryColor
292
283
  this.inactiveBorderColor = theme.semanticColors.teal[600]
293
284
  } else {
294
- this.primaryColor = theme.semanticColors.teal[100]
295
- this.secondaryColor = theme.semanticColors.purple[500]
296
- this.inactiveColor = theme.semanticColors.purple[500]
285
+ this.primaryColor = theme.semanticColors.teal[50]
286
+ this.secondaryColor = theme.semanticColors.teal[800]
287
+ this.inactiveColor = theme.semanticColors.teal[50]
297
288
  this.borderColor = this.secondaryColor
298
- this.inactiveBorderColor = theme.semanticColors.teal[200]
289
+ this.inactiveBorderColor = theme.semanticColors.teal[50]
299
290
  }
300
291
  },
301
292
  methods: {
302
- selectItem(item) {
303
- if (this.disabled || item.disabled) {
293
+ selectItem(value) {
294
+ if (this.disabled) {
304
295
  return
305
296
  }
306
- this.$emit('on-switch-change', item.value)
297
+ this.$emit('on-switch-change', value)
307
298
  },
308
299
  },
309
300
  }
@@ -65,9 +65,9 @@ describe('SwitchField', () => {
65
65
  })
66
66
 
67
67
  it('renders the info text only when a message is provided', () => {
68
- expect(
69
- mountSwitch({ label: 'Mode' }).findComponent(InfoText).exists()
70
- ).toBe(false)
68
+ expect(mountSwitch({ label: 'Mode' }).findComponent(InfoText).exists()).toBe(
69
+ false
70
+ )
71
71
  expect(
72
72
  mountSwitch({ label: 'Mode', infoTextMessage: 'Helpful hint' })
73
73
  .findComponent(InfoText)
@@ -32,7 +32,6 @@
32
32
  role="checkbox"
33
33
  :size="size"
34
34
  tabindex="0"
35
- :undefined-state="undefinedState"
36
35
  @keydown.space.prevent="onToggleChange"
37
36
  >
38
37
  <ToggleBackground
@@ -42,7 +41,6 @@
42
41
  data-test-id="toggle_background"
43
42
  :disabled="disabled"
44
43
  :is-checked="isChecked"
45
- :undefined-state="undefinedState"
46
44
  />
47
45
  <ToggleDot
48
46
  data-test-id="toggle_dot"
@@ -51,7 +49,6 @@
51
49
  :size="size"
52
50
  :toggle-color="toggleColor"
53
51
  :toggle-margin="toggleMargin"
54
- :undefined-state="undefinedState"
55
52
  />
56
53
  </ToggleWrapper>
57
54
  <LabelContainer
@@ -128,7 +125,6 @@
128
125
  disabled: Boolean,
129
126
  backgroundColor: String,
130
127
  isChecked: Boolean,
131
- undefinedState: Boolean,
132
128
  }
133
129
  const LabelText = styled('div', toggleAttrs)`
134
130
  color: ${(props) =>
@@ -144,8 +140,6 @@
144
140
  border: ${(props) =>
145
141
  props.disabled
146
142
  ? '1px solid ' + props.theme.colors.disabled
147
- : props.undefinedState
148
- ? '1px solid ' + props.theme.colors.grey3
149
143
  : props.isChecked
150
144
  ? props.backgroundColor
151
145
  ? '1px solid ' + props.backgroundColor
@@ -181,7 +175,6 @@
181
175
  backgroundColor: String,
182
176
  isChecked: Boolean,
183
177
  disabled: Boolean,
184
- undefinedState: Boolean,
185
178
  }
186
179
  const ToggleBackground = styled('span', backgroundAttrs)`
187
180
  display: block;
@@ -191,8 +184,6 @@
191
184
  background-color: ${(props) =>
192
185
  props.disabled
193
186
  ? props.theme.colors.grey5
194
- : props.undefinedState
195
- ? props.theme.colors.grey3
196
187
  : props.isChecked
197
188
  ? props.backgroundColor
198
189
  ? props.backgroundColor
@@ -207,7 +198,6 @@
207
198
  size: String,
208
199
  disabled: Boolean,
209
200
  toggleMargin: String,
210
- undefinedState: Boolean,
211
201
  }
212
202
  const ToggleDot = styled('span', toggleProps)`
213
203
  position: absolute;
@@ -243,13 +233,7 @@
243
233
  border-radius: 100%;
244
234
  transition: transform 0.4s ease;
245
235
  transform: ${(props) =>
246
- props.undefinedState
247
- ? props.size === 'medium'
248
- ? 'translateX(15px)'
249
- : props.size === 'small'
250
- ? 'translateX(6px)'
251
- : 'translateX(15px)'
252
- : props.isChecked
236
+ props.isChecked
253
237
  ? props.size === 'medium'
254
238
  ? 'translateX(25px)'
255
239
  : props.size === 'small'
@@ -352,11 +336,6 @@
352
336
  default: null,
353
337
  required: false,
354
338
  },
355
- undefinedState: {
356
- type: Boolean,
357
- default: false,
358
- required: false,
359
- },
360
339
  },
361
340
  methods: {
362
341
  onToggleChange() {
@@ -77,16 +77,16 @@ describe('RCToggle', () => {
77
77
  expect(wrapper.find('[data-test-id="label_left_container"]').exists()).toBe(
78
78
  false
79
79
  )
80
- expect(
81
- wrapper.find('[data-test-id="label_right_container"]').exists()
82
- ).toBe(false)
80
+ expect(wrapper.find('[data-test-id="label_right_container"]').exists()).toBe(
81
+ false
82
+ )
83
83
  })
84
84
 
85
85
  it('renders the label on the right by default', () => {
86
86
  const wrapper = mountToggle({ label: 'My Label' })
87
- expect(
88
- wrapper.find('[data-test-id="label_right_container"]').exists()
89
- ).toBe(true)
87
+ expect(wrapper.find('[data-test-id="label_right_container"]').exists()).toBe(
88
+ true
89
+ )
90
90
  expect(wrapper.find('[data-test-id="label_left_container"]').exists()).toBe(
91
91
  false
92
92
  )
@@ -98,9 +98,9 @@ describe('RCToggle', () => {
98
98
  expect(wrapper.find('[data-test-id="label_left_container"]').exists()).toBe(
99
99
  true
100
100
  )
101
- expect(
102
- wrapper.find('[data-test-id="label_right_container"]').exists()
103
- ).toBe(false)
101
+ expect(wrapper.find('[data-test-id="label_right_container"]').exists()).toBe(
102
+ false
103
+ )
104
104
  })
105
105
 
106
106
  it('renders the info text only when infoTextMessage is provided', () => {
@@ -0,0 +1,145 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import NavigationSideMenu from '@/components/navigationSideMenu'
3
+
4
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
5
+ // need to mock this due to how jest handles import.meta
6
+ fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
7
+ }))
8
+
9
+ const menuData = [
10
+ {
11
+ groupName: 'Project',
12
+ children: [
13
+ { id: 'technologies', name: 'Technologies', dataId: 'nav_technologies' },
14
+ { id: 'finance', name: 'Finance', dataId: 'nav_finance' },
15
+ ],
16
+ },
17
+ {
18
+ groupName: 'Workflow',
19
+ children: [
20
+ {
21
+ id: 'workflow_template',
22
+ name: 'Workflow template',
23
+ dataId: 'nav_workflow_template',
24
+ },
25
+ ],
26
+ },
27
+ ]
28
+
29
+ const mountMenu = (props = {}) =>
30
+ mount(NavigationSideMenu, { props: { menuData, ...props } })
31
+
32
+ const item = (wrapper, dataId) => wrapper.find(`[data-id="${dataId}"]`)
33
+ const section = (wrapper, key) => wrapper.find(`[data-id="nav_section_${key}"]`)
34
+
35
+ describe('navigationSideMenu/index.vue', () => {
36
+ describe('default state', () => {
37
+ it('renders a header for each non-empty group', () => {
38
+ const wrapper = mountMenu()
39
+
40
+ expect(section(wrapper, 'Project').exists()).toBe(true)
41
+ expect(section(wrapper, 'Workflow').exists()).toBe(true)
42
+ })
43
+
44
+ it('shows every group item when nothing is collapsed', () => {
45
+ const wrapper = mountMenu()
46
+
47
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(true)
48
+ expect(item(wrapper, 'nav_finance').exists()).toBe(true)
49
+ expect(item(wrapper, 'nav_workflow_template').exists()).toBe(true)
50
+ })
51
+
52
+ it('reports no group as collapsed initially', () => {
53
+ const wrapper = mountMenu()
54
+
55
+ expect(wrapper.vm.isGroupCollapsed('Project')).toBe(false)
56
+ expect(wrapper.vm.isGroupCollapsed('Workflow')).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('toggling a section', () => {
61
+ it("hides that section's items when its header is clicked, and shows them again on a second click", async () => {
62
+ const wrapper = mountMenu()
63
+
64
+ await section(wrapper, 'Project').trigger('click')
65
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(false)
66
+ expect(item(wrapper, 'nav_finance').exists()).toBe(false)
67
+
68
+ await section(wrapper, 'Project').trigger('click')
69
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(true)
70
+ expect(item(wrapper, 'nav_finance').exists()).toBe(true)
71
+ })
72
+
73
+ it('collapses sections independently — collapsing one leaves the others expanded', async () => {
74
+ const wrapper = mountMenu()
75
+
76
+ await section(wrapper, 'Project').trigger('click')
77
+
78
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(false)
79
+ expect(item(wrapper, 'nav_workflow_template').exists()).toBe(true)
80
+ expect(wrapper.vm.isGroupCollapsed('Project')).toBe(true)
81
+ expect(wrapper.vm.isGroupCollapsed('Workflow')).toBe(false)
82
+ })
83
+
84
+ it('emits section-collapse-toggle with the key and post-toggle collapsed state', async () => {
85
+ const wrapper = mountMenu()
86
+
87
+ await section(wrapper, 'Project').trigger('click')
88
+ await section(wrapper, 'Project').trigger('click')
89
+
90
+ const events = wrapper.emitted('section-collapse-toggle')
91
+ expect(events).toHaveLength(2)
92
+ expect(events[0]).toEqual([{ key: 'Project', collapsed: true }])
93
+ expect(events[1]).toEqual([{ key: 'Project', collapsed: false }])
94
+ })
95
+ })
96
+
97
+ describe('groupKey', () => {
98
+ it('prefers an explicit id over the group name', () => {
99
+ const wrapper = mountMenu()
100
+
101
+ expect(wrapper.vm.groupKey({ id: 'a', groupName: 'B' }, 0)).toBe('a')
102
+ })
103
+
104
+ it('falls back to the group name when there is no id', () => {
105
+ const wrapper = mountMenu()
106
+
107
+ expect(wrapper.vm.groupKey({ groupName: 'B' }, 0)).toBe('B')
108
+ })
109
+
110
+ it('falls back to the index (as a string) when neither id nor name is present', () => {
111
+ const wrapper = mountMenu()
112
+
113
+ expect(wrapper.vm.groupKey({}, 3)).toBe('3')
114
+ })
115
+ })
116
+
117
+ describe('edge cases', () => {
118
+ it('mounts without error and renders no sections when menuData is empty', () => {
119
+ const wrapper = mount(NavigationSideMenu, { props: { menuData: [] } })
120
+
121
+ expect(wrapper.find('[data-id^="nav_section_"]').exists()).toBe(false)
122
+ })
123
+
124
+ it('treats groups that share a groupName as one section (name is the key)', async () => {
125
+ const dupData = [
126
+ {
127
+ groupName: 'Other',
128
+ children: [{ id: 'a', name: 'A', dataId: 'nav_a' }],
129
+ },
130
+ {
131
+ groupName: 'Other',
132
+ children: [{ id: 'b', name: 'B', dataId: 'nav_b' }],
133
+ },
134
+ ]
135
+ const wrapper = mount(NavigationSideMenu, {
136
+ props: { menuData: dupData },
137
+ })
138
+
139
+ await wrapper.findAll('[data-id="nav_section_Other"]')[0].trigger('click')
140
+
141
+ expect(item(wrapper, 'nav_a').exists()).toBe(false)
142
+ expect(item(wrapper, 'nav_b').exists()).toBe(false)
143
+ })
144
+ })
145
+ })
@@ -120,8 +120,31 @@
120
120
  v-if="group.children?.length"
121
121
  :is-collapsed="isCollapsed"
122
122
  >
123
- <GroupName v-if="!isCollapsed">{{ group.groupName }}</GroupName>
124
- <ItemList>
123
+ <GroupHeader
124
+ v-if="!isCollapsed"
125
+ :data-id="`nav_section_${groupKey(group, groupIdx)}`"
126
+ :data-qa-id="`nav_section_${groupKey(group, groupIdx)}`"
127
+ @click="toggleGroup(groupKey(group, groupIdx))"
128
+ >
129
+ <GroupName>{{ group.groupName }}</GroupName>
130
+ <GroupChevron>
131
+ <IconComponent
132
+ :color="theme.semanticColors.grey[700]"
133
+ :hovered-color="theme.semanticColors.purple[500]"
134
+ :name="
135
+ isGroupCollapsed(groupKey(group, groupIdx))
136
+ ? 'chevron_down'
137
+ : 'chevron_up'
138
+ "
139
+ size="10px"
140
+ />
141
+ </GroupChevron>
142
+ </GroupHeader>
143
+ <ItemList
144
+ v-if="
145
+ isCollapsed || !isGroupCollapsed(groupKey(group, groupIdx))
146
+ "
147
+ >
125
148
  <template
126
149
  v-for="(item, itemIdx) in group.children"
127
150
  :key="item.id ?? itemIdx"
@@ -137,7 +160,7 @@
137
160
  :is-disabled="item.isDisabled"
138
161
  @click="
139
162
  !item.isDisabled &&
140
- $emit('on-menu-item-click', { item })
163
+ $emit('on-menu-item-click', { item })
141
164
  "
142
165
  >
143
166
  <IconCell :is-collapsed="isCollapsed">
@@ -174,7 +197,7 @@
174
197
  type="tertiary"
175
198
  @click="
176
199
  !item.isDisabled &&
177
- $emit('on-menu-item-click', { item })
200
+ $emit('on-menu-item-click', { item })
178
201
  "
179
202
  />
180
203
  </template>
@@ -244,7 +267,7 @@
244
267
  :is-disabled="sub.isDisabled"
245
268
  @click="
246
269
  !sub.isDisabled &&
247
- $emit('on-menu-item-click', { item: sub })
270
+ $emit('on-menu-item-click', { item: sub })
248
271
  "
249
272
  >
250
273
  <span>{{ sub.name }}</span>
@@ -307,8 +330,8 @@
307
330
  p.isCollapsed
308
331
  ? `${NAV_SIDEBAR_COLLAPSED_OUTER_WIDTH_PX}px`
309
332
  : p.expandedWidth
310
- ? `${p.expandedWidth}px`
311
- : 'max-content'};
333
+ ? `${p.expandedWidth}px`
334
+ : 'max-content'};
312
335
  min-width: ${(p) =>
313
336
  p.isCollapsed ? `${NAV_SIDEBAR_COLLAPSED_OUTER_WIDTH_PX}px` : '0'};
314
337
  border-right: 1px solid ${(p) => p.theme.semanticColors.grey[300]};
@@ -418,6 +441,18 @@
418
441
  padding: ${(p) => (p.isCollapsed ? '10px 8px' : '10px 16px')};
419
442
  `
420
443
 
444
+ const GroupHeader = styled.div`
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: space-between;
448
+ gap: 6px;
449
+ cursor: pointer;
450
+
451
+ &:hover > div:first-child {
452
+ color: ${(p) => p.theme.semanticColors.purple[500]};
453
+ }
454
+ `
455
+
421
456
  const GroupName = styled.div`
422
457
  font-size: 10px;
423
458
  font-weight: 400;
@@ -426,6 +461,12 @@
426
461
  line-height: 150%;
427
462
  `
428
463
 
464
+ const GroupChevron = styled.div`
465
+ display: flex;
466
+ align-items: center;
467
+ flex-shrink: 0;
468
+ `
469
+
429
470
  const ItemList = styled.div`
430
471
  display: flex;
431
472
  flex-direction: column;
@@ -758,7 +799,9 @@
758
799
  ScrollbarThumb,
759
800
  GroupsList,
760
801
  GroupSection,
802
+ GroupHeader,
761
803
  GroupName,
804
+ GroupChevron,
762
805
  ItemList,
763
806
  MenuItem,
764
807
  IconCell,
@@ -837,6 +880,7 @@
837
880
  'on-menu-item-click',
838
881
  'on-footer-button-click',
839
882
  'sidebar-dimensions',
883
+ 'section-collapse-toggle',
840
884
  ],
841
885
  data() {
842
886
  return {
@@ -844,6 +888,7 @@
844
888
  isCollapsed: false,
845
889
  responsibleSelectSurfaceOpen: false,
846
890
  openDropdownId: null,
891
+ collapsedGroups: {},
847
892
  isWrapperHovered: false,
848
893
  expandedWidth: null,
849
894
  scrollThumbStyle: {},
@@ -1120,6 +1165,27 @@
1120
1165
  this.openDropdownId = this.openDropdownId === id ? null : id
1121
1166
  }
1122
1167
  },
1168
+ groupKey(group, idx) {
1169
+ return group.id ?? group.groupName ?? String(idx)
1170
+ },
1171
+ isGroupCollapsed(key) {
1172
+ return !!this.collapsedGroups[key]
1173
+ },
1174
+ toggleGroup(key) {
1175
+ if (this.collapsedGroups[key]) {
1176
+ delete this.collapsedGroups[key]
1177
+ } else {
1178
+ this.collapsedGroups[key] = true
1179
+ }
1180
+ this.$emit('section-collapse-toggle', {
1181
+ key,
1182
+ collapsed: this.isGroupCollapsed(key),
1183
+ })
1184
+ this.$nextTick(() => {
1185
+ this.updateScrollThumb()
1186
+ this.updateExpandedWidth()
1187
+ })
1188
+ },
1123
1189
  },
1124
1190
  }
1125
1191
  </script>
@@ -0,0 +1,133 @@
1
+ import { action } from '@storybook/addon-actions'
2
+ import NavigationSideMenu from './index.vue'
3
+ import theme from '@/assets/theme'
4
+
5
+ // Grouped menu mirroring the beta settings navigation: each top-level entry is a
6
+ // section ({ groupName, children }). Click a section header (e.g. "OFFER") to
7
+ // collapse/expand its items — sections collapse independently.
8
+ const settingsMenu = [
9
+ {
10
+ groupName: 'Project',
11
+ children: [
12
+ {
13
+ id: 'technologies',
14
+ name: 'Technologies',
15
+ dataId: 'nav_technologies',
16
+ icon: 'settings',
17
+ },
18
+ {
19
+ id: 'finance',
20
+ name: 'Finance',
21
+ dataId: 'nav_finance',
22
+ icon: 'documents',
23
+ },
24
+ ],
25
+ },
26
+ {
27
+ groupName: 'Workflow',
28
+ children: [
29
+ {
30
+ id: 'workflow_template',
31
+ name: 'Workflow template',
32
+ dataId: 'nav_workflow_template',
33
+ icon: 'settings',
34
+ },
35
+ {
36
+ id: 'activity_type',
37
+ name: 'Activity type definition',
38
+ dataId: 'nav_activity_type',
39
+ icon: 'documents',
40
+ },
41
+ ],
42
+ },
43
+ {
44
+ groupName: 'Offer',
45
+ children: [
46
+ {
47
+ id: 'branding',
48
+ name: 'Branding',
49
+ dataId: 'nav_branding',
50
+ icon: 'settings',
51
+ },
52
+ {
53
+ id: 'document_templates',
54
+ name: 'Document templates',
55
+ dataId: 'nav_document_templates',
56
+ icon: 'documents',
57
+ // An item-level dropdown (its own children) — orthogonal to section collapse.
58
+ children: [
59
+ { id: 'quotation', name: 'Quotation', dataId: 'nav_quotation' },
60
+ { id: 'invoice', name: 'Invoice', dataId: 'nav_invoice' },
61
+ ],
62
+ },
63
+ ],
64
+ },
65
+ ]
66
+
67
+ const projectManagerOptions = [
68
+ { id: 1, full_name: 'Firstname Lastname' },
69
+ { id: 2, full_name: 'Alex Meyer' },
70
+ ]
71
+
72
+ export default {
73
+ title: 'Components/Navigation/NavigationSideMenu',
74
+ component: NavigationSideMenu,
75
+ tags: ['autodocs'],
76
+ parameters: {
77
+ layout: 'fullscreen',
78
+ },
79
+ }
80
+
81
+ const Template = (args) => ({
82
+ components: { NavigationSideMenu },
83
+ setup() {
84
+ const onSectionToggle = (payload) =>
85
+ action('section-collapse-toggle')(payload)
86
+ const onItemClick = (payload) => action('on-menu-item-click')(payload)
87
+ return { args, onSectionToggle, onItemClick }
88
+ },
89
+ template: `
90
+ <div data-test-id="navigation-side-menu-story" style="display: flex; min-height: 100vh; background: #f5f5f5;">
91
+ <NavigationSideMenu
92
+ v-bind="args"
93
+ @section-collapse-toggle="onSectionToggle"
94
+ @on-menu-item-click="onItemClick"
95
+ />
96
+ <main style="flex: 1; padding: 24px;">Main content area</main>
97
+ </div>
98
+ `,
99
+ provide: {
100
+ theme,
101
+ },
102
+ })
103
+
104
+ // Default: all sections expanded. Click a section header to collapse/expand it;
105
+ // multiple sections can be collapsed at once (independent, not accordion).
106
+ export const CollapsibleSections = Template.bind({})
107
+ CollapsibleSections.args = {
108
+ menuData: settingsMenu,
109
+ headerTitle: 'Settings',
110
+ activeItemId: 'technologies',
111
+ }
112
+
113
+ // An item that has its own children ("Document templates") renders an item-level
114
+ // dropdown, which behaves independently of section collapse.
115
+ export const WithNestedDropdown = Template.bind({})
116
+ WithNestedDropdown.args = {
117
+ menuData: settingsMenu,
118
+ headerTitle: 'Settings',
119
+ activeItemId: 'quotation',
120
+ activeParentId: 'document_templates',
121
+ }
122
+
123
+ // The project-view placement of the same component: project-manager select in the
124
+ // header plus a footer button. Section collapse works identically here.
125
+ export const ProjectPlacement = Template.bind({})
126
+ ProjectPlacement.args = {
127
+ menuData: settingsMenu,
128
+ showProjectManagerSelect: true,
129
+ projectManagerOptions,
130
+ projectManagerId: 1,
131
+ footerButtonText: 'Documents',
132
+ activeItemId: 'finance',
133
+ }
@@ -113,8 +113,7 @@
113
113
  size: {
114
114
  type: String,
115
115
  default: 'medium',
116
- validator: (value) =>
117
- ['tiny', 'small', 'medium', 'large'].includes(value),
116
+ validator: (value) => ['small', 'medium', 'large'].includes(value),
118
117
  },
119
118
  isWhite: {
120
119
  required: false,
@@ -1,6 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Uploaded to svgrepo.com" width="800px" height="800px" viewBox="0 0 32 32" xml:space="preserve">
2
- <style type="text/css">
3
- .linesandangles_een{fill:#000000;}
4
- </style>
5
- <path class="linesandangles_een" d="M26.281,30l-2-8H25v-5h-8V2h-2v15H7v5h0.719l-2,8H26.281z M23.719,28H21v-4h-2v4h-2v-4h-2v4h-2 v-4h-2v4H8.281l1.5-6h12.438L23.719,28z M9,19h14v1H9V19z"/>
6
- </svg>
@@ -1,5 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="10" height="14" viewBox="0 0 10 14" fill="none">
2
- <g transform="translate(10,0) rotate(90)">
3
- <path fill-rule="evenodd" clip-rule="evenodd" d="M0.199219 9.85718V0.142822H1.75351L1.75352 9.85718H0.199219ZM5.38021 8.20572L2.14209 4.99999L5.38021 1.79425V4.19855L8.61833 4.19855V1.79425L11.8564 4.99999L8.61832 8.20572L8.61833 5.80142L5.38021 5.80142V8.20572ZM12.2449 0.142822V9.85718H13.7992V0.142822H12.2449Z" fill="white"/>
4
- </g>
5
- </svg>
@@ -1,3 +0,0 @@
1
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <path d="M2 4H12V8L2 10V4Z" fill="white" />
3
- </svg>
@@ -1,3 +0,0 @@
1
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <path d="M3 10L6 4H13L10 10H3Z" fill="white" />
3
- </svg>
@@ -1 +0,0 @@
1
- module.exports = 'test-file-stub'