@eturnity/eturnity_reusable_components 9.25.17 → 9.28.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.
@@ -2,53 +2,223 @@ import { computed, ref } from 'vue'
2
2
  import Select from './index.vue'
3
3
  import Option from './option/index.vue'
4
4
 
5
- const SELECT_SAMPLE_OPTIONS = [
6
- { key: '1', label: 'value one' },
7
- { key: '2', label: 'value two' },
8
- { key: '3', label: 'value three' },
9
- { key: '4', label: 'value four' },
5
+ // To use:
6
+ // import Select from '@eturnity/eturnity_reusable_components/src/components/inputs/select'
7
+ // import Option from '@eturnity/eturnity_reusable_components/src/components/inputs/select/option'
8
+ // <Select v-bind="props">
9
+ // <template #selector="{ selectedValue }">{{ selectedValue.label }}</template>
10
+ // <template #dropdown>
11
+ // <Option v-for="o in options" :key="o.key" :value="o">{{ o.label }}</Option>
12
+ // </template>
13
+ // </Select>
14
+
15
+ // The search field only appears once the dropdown holds at least
16
+ // `minOptionLength` (5 by default) options — see the WithSearchField story.
17
+ const FEW_OPTIONS = [
18
+ { key: '1', label: 'Apple' },
19
+ { key: '2', label: 'Banana' },
20
+ { key: '3', label: 'Cherry' },
21
+ ]
22
+
23
+ const MANY_OPTIONS = [
24
+ { key: '1', label: 'Austria' },
25
+ { key: '2', label: 'Belgium' },
26
+ { key: '3', label: 'Denmark' },
27
+ { key: '4', label: 'France' },
28
+ { key: '5', label: 'Germany' },
29
+ { key: '6', label: 'Italy' },
30
+ { key: '7', label: 'Spain' },
31
+ { key: '8', label: 'Switzerland' },
10
32
  ]
11
33
 
12
34
  export default {
13
35
  title: 'Components/Inputs/Select',
14
36
  component: Select,
37
+ tags: ['autodocs'],
38
+ parameters: {
39
+ // The select relies on scoped slots (#selector="{ selectedValue }"), which
40
+ // Storybook's dynamic source generator cannot serialise (it throws
41
+ // "selectedValue is not defined"). Show the static story code instead.
42
+ docs: {
43
+ source: { type: 'code' },
44
+ },
45
+ },
46
+ argTypes: {
47
+ label: { description: 'Text shown above/beside the select' },
48
+ value: {
49
+ description: 'The currently selected value (any type, often an object)',
50
+ control: false,
51
+ },
52
+ options: {
53
+ description: 'Demo-only: the list of options rendered in the dropdown',
54
+ control: false,
55
+ },
56
+ alignItems: {
57
+ description: 'Label position relative to the select',
58
+ control: 'inline-radio',
59
+ options: ['horizontal', 'vertical'],
60
+ },
61
+ colorMode: {
62
+ description: 'Visual theme of the select',
63
+ control: 'inline-radio',
64
+ options: ['light', 'dark', 'transparent'],
65
+ },
66
+ isSearchable: {
67
+ description:
68
+ 'Whether a search field is shown once the option count reaches minOptionLength',
69
+ control: 'boolean',
70
+ },
71
+ minOptionLength: {
72
+ description: 'Minimum number of options before the search field appears',
73
+ control: 'number',
74
+ },
75
+ searchPlacement: {
76
+ description: 'Where the search field renders',
77
+ control: 'inline-radio',
78
+ options: ['trigger', 'dropdown'],
79
+ },
80
+ disabled: { description: 'Disables the select', control: 'boolean' },
81
+ hasError: {
82
+ description: 'Shows the select in an error state',
83
+ control: 'boolean',
84
+ },
85
+ errorMessage: {
86
+ description: 'Message rendered below the select when hasError is true',
87
+ control: 'text',
88
+ },
89
+ selectWidth: { description: 'Width of the select trigger' },
90
+ optionWidth: { description: 'Width of the dropdown menu' },
91
+
92
+ // slots
93
+ selector: { description: 'Renders the selected value in the trigger' },
94
+ dropdown: { description: 'Holds the <Option> elements' },
95
+ },
15
96
  }
16
97
 
17
98
  const Template = (args) => ({
18
- // Components used in your story `template` are defined in the `components` object
19
99
  components: { Select, Option },
20
- // The story's `args` need to be mapped into the template through the `setup()` method
21
100
  setup() {
22
- return { args, selectSampleOptions: SELECT_SAMPLE_OPTIONS }
101
+ const options = args.options || FEW_OPTIONS
102
+ // Give dark / transparent modes a dark backdrop so the white text is legible.
103
+ const wrapperStyle = ['dark', 'transparent'].includes(args.colorMode)
104
+ ? 'background: #2b2b2b; padding: 20px; border-radius: 4px;'
105
+ : 'padding: 20px;'
106
+ return { args, options, wrapperStyle }
23
107
  },
24
- template: `<Select v-bind="args">
25
- <template #selector="{ selectedValue }">
26
- value selected: {{ selectedValue.label }}
27
- </template>
28
- <template #dropdown>
29
- <Option
30
- v-for="item in selectSampleOptions"
31
- :key="item.key"
32
- :value="item"
33
- >
34
- {{ item.label }}
35
- </Option>
36
- </template>
37
- </Select>`,
108
+ template: `
109
+ <div :style="wrapperStyle">
110
+ <Select v-bind="args">
111
+ <template #selector="{ selectedValue }">
112
+ {{ selectedValue ? selectedValue.label : 'Select an option' }}
113
+ </template>
114
+ <template #dropdown>
115
+ <Option
116
+ v-for="item in options"
117
+ :key="item.key"
118
+ :value="item"
119
+ >
120
+ {{ item.label }}
121
+ </Option>
122
+ </template>
123
+ </Select>
124
+ </div>
125
+ `,
38
126
  })
39
127
 
40
128
  export const Default = Template.bind({})
41
129
  Default.args = {
42
- hoverDropdown: false,
43
- selectWidth: '100%',
44
- optionWidth: '50%',
45
- label: 'Service territory',
130
+ label: 'Fruit',
131
+ alignItems: 'vertical',
132
+ options: FEW_OPTIONS,
133
+ value: FEW_OPTIONS[0],
134
+ }
135
+
136
+ /**
137
+ * With more than 5 options the search field is shown automatically so the user
138
+ * can filter the list. Open the dropdown to see the search input.
139
+ */
140
+ export const WithSearchField = Template.bind({})
141
+ WithSearchField.args = {
142
+ label: 'Country',
143
+ alignItems: 'vertical',
144
+ options: MANY_OPTIONS,
145
+ value: MANY_OPTIONS[4],
146
+ }
147
+
148
+ /**
149
+ * The search field can also be rendered inside the dropdown itself instead of
150
+ * in the trigger, via `searchPlacement="dropdown"`.
151
+ */
152
+ export const SearchInsideDropdown = Template.bind({})
153
+ SearchInsideDropdown.args = {
154
+ label: 'Country',
155
+ alignItems: 'vertical',
156
+ searchPlacement: 'dropdown',
157
+ options: MANY_OPTIONS,
158
+ value: MANY_OPTIONS[4],
159
+ }
160
+
161
+ /**
162
+ * Set `minOptionLength` to change how many options trigger the search field.
163
+ * Here it appears with only 3 options.
164
+ */
165
+ export const LowerSearchThreshold = Template.bind({})
166
+ LowerSearchThreshold.args = {
167
+ label: 'Fruit',
168
+ alignItems: 'vertical',
169
+ minOptionLength: 3,
170
+ options: FEW_OPTIONS,
171
+ value: FEW_OPTIONS[0],
172
+ }
173
+
174
+ export const Disabled = Template.bind({})
175
+ Disabled.args = {
176
+ label: 'Fruit',
177
+ alignItems: 'vertical',
178
+ disabled: true,
179
+ options: FEW_OPTIONS,
180
+ value: FEW_OPTIONS[0],
181
+ }
182
+
183
+ export const WithError = Template.bind({})
184
+ WithError.args = {
185
+ label: 'Fruit',
46
186
  alignItems: 'vertical',
47
- value: SELECT_SAMPLE_OPTIONS[1],
187
+ hasError: true,
188
+ errorMessage: 'This field is required',
189
+ options: FEW_OPTIONS,
190
+ value: null,
191
+ }
192
+
193
+ export const HorizontalLabel = Template.bind({})
194
+ HorizontalLabel.args = {
195
+ label: 'Fruit',
196
+ alignItems: 'horizontal',
197
+ options: FEW_OPTIONS,
198
+ value: FEW_OPTIONS[0],
199
+ }
200
+
201
+ export const ColorModeDark = Template.bind({})
202
+ ColorModeDark.args = {
203
+ label: 'Fruit',
204
+ alignItems: 'vertical',
205
+ colorMode: 'dark',
206
+ options: FEW_OPTIONS,
207
+ value: FEW_OPTIONS[0],
208
+ }
209
+
210
+ export const ColorModeTransparent = Template.bind({})
211
+ ColorModeTransparent.args = {
212
+ label: 'Fruit',
213
+ alignItems: 'vertical',
214
+ colorMode: 'transparent',
215
+ options: FEW_OPTIONS,
216
+ value: FEW_OPTIONS[0],
48
217
  }
49
218
 
50
219
  /**
51
- * Customizable Select
220
+ * A customizable select lets the user type a value that is not in the list and
221
+ * add it as a new option.
52
222
  */
53
223
  const CustomizableTemplate = (args) => ({
54
224
  components: { Select, Option },
@@ -60,7 +230,7 @@ const CustomizableTemplate = (args) => ({
60
230
  label: customOptionValue,
61
231
  })
62
232
  const customizableOptions = computed(() => {
63
- const options = [...SELECT_SAMPLE_OPTIONS]
233
+ const options = [...FEW_OPTIONS]
64
234
  if (customOption.value) {
65
235
  options.push(customOption.value)
66
236
  }
@@ -83,33 +253,37 @@ const CustomizableTemplate = (args) => ({
83
253
  onInputChange,
84
254
  }
85
255
  },
86
- template: `<Select
87
- v-bind="args"
88
- :custom-option-adapter="createCustomOption"
89
- @input-change="onInputChange"
90
- >
91
- <template #selector="{ selectedValue }">
92
- value selected: {{ selectedValue.label }}
93
- </template>
94
- <template #dropdown>
95
- <Option
96
- v-for="(item) in customizableOptions"
97
- :key="item.key"
98
- :value="item"
99
- >
100
- {{ item.label }}
101
- </Option>
102
- </template>
103
- <template #customOption="{ customOptionValue }">
104
- <Option
105
- data-custom-option
106
- :value="createCustomOption(customOptionValue)"
256
+ template: `
257
+ <div style="padding: 20px;">
258
+ <Select
259
+ v-bind="args"
260
+ :custom-option-adapter="createCustomOption"
261
+ @input-change="onInputChange"
107
262
  >
108
- {{ customOptionValue }}
109
- <div>(custom)</div>
110
- </Option>
111
- </template>
112
- </Select>`,
263
+ <template #selector="{ selectedValue }">
264
+ {{ selectedValue ? selectedValue.label : 'Select an option' }}
265
+ </template>
266
+ <template #dropdown>
267
+ <Option
268
+ v-for="item in customizableOptions"
269
+ :key="item.key"
270
+ :value="item"
271
+ >
272
+ {{ item.label }}
273
+ </Option>
274
+ </template>
275
+ <template #customOption="{ customOptionValue }">
276
+ <Option
277
+ data-custom-option
278
+ :value="createCustomOption(customOptionValue)"
279
+ >
280
+ {{ customOptionValue }}
281
+ <div>(custom)</div>
282
+ </Option>
283
+ </template>
284
+ </Select>
285
+ </div>
286
+ `,
113
287
  })
114
288
 
115
289
  export const Customizable = CustomizableTemplate.bind({})
@@ -117,5 +291,5 @@ Customizable.args = {
117
291
  label: 'Customizable select',
118
292
  alignItems: 'vertical',
119
293
  isCustomizable: true,
120
- value: SELECT_SAMPLE_OPTIONS[1],
294
+ value: FEW_OPTIONS[1],
121
295
  }
@@ -0,0 +1,59 @@
1
+ /* eslint-disable */
2
+ import { mount } from '@vue/test-utils'
3
+ import RcSlider from '@/components/inputs/slider'
4
+
5
+ // Isolate the wrapper from the third-party @vueform/slider, which does not
6
+ // render meaningfully under jsdom. The stub records the props it receives and
7
+ // lets us emit its `update` event.
8
+ jest.mock('@vueform/slider', () => ({
9
+ __esModule: true,
10
+ default: {
11
+ name: 'VueformSlider',
12
+ props: ['max', 'min', 'value', 'tooltips'],
13
+ emits: ['update'],
14
+ template: '<div data-test-id="vueform-slider"></div>',
15
+ },
16
+ }))
17
+
18
+ describe('RcSlider', () => {
19
+ const mountSlider = (props = {}) => mount(RcSlider, { props })
20
+ const innerSlider = (wrapper) =>
21
+ wrapper.findComponent({ name: 'VueformSlider' })
22
+
23
+ it('renders the current value with its unit', () => {
24
+ const wrapper = mountSlider({ value: 35, unit: '%' })
25
+ expect(wrapper.text()).toContain('35')
26
+ expect(wrapper.text()).toContain('%')
27
+ })
28
+
29
+ it('forwards value, min and max to the underlying slider', () => {
30
+ const inner = innerSlider(
31
+ mountSlider({ value: 120, minValue: 10, maxValue: 500 })
32
+ )
33
+ expect(inner.props('value')).toBe(120)
34
+ expect(inner.props('min')).toBe(10)
35
+ expect(inner.props('max')).toBe(500)
36
+ })
37
+
38
+ it('applies the documented defaults', () => {
39
+ const wrapper = mountSlider()
40
+ const inner = innerSlider(wrapper)
41
+ expect(inner.props('value')).toBe(0)
42
+ expect(inner.props('min')).toBe(0)
43
+ expect(inner.props('max')).toBe(100)
44
+ expect(wrapper.text()).toContain('0')
45
+ })
46
+
47
+ it('always disables the built-in tooltips', () => {
48
+ expect(innerSlider(mountSlider()).props('tooltips')).toBe(false)
49
+ })
50
+
51
+ it('emits change with the new value when the slider updates', async () => {
52
+ const wrapper = mountSlider({ value: 10 })
53
+
54
+ await innerSlider(wrapper).vm.$emit('update', 42)
55
+
56
+ expect(wrapper.emitted('change')).toHaveLength(1)
57
+ expect(wrapper.emitted('change')[0]).toEqual([42])
58
+ })
59
+ })
@@ -11,7 +11,9 @@ export default {
11
11
  docs: {
12
12
  description: {
13
13
  component:
14
- 'Wrapper around `@vueform/slider` with Eturnity styling. The inner control is registered as `VueformSlider` so it cannot recurse into the host component.',
14
+ 'Wrapper around `@vueform/slider` with Eturnity styling. The inner ' +
15
+ 'control is registered as `VueformSlider` so it cannot recurse into ' +
16
+ 'the host component.',
15
17
  },
16
18
  },
17
19
  },
@@ -31,7 +33,7 @@ const Template = (args) => ({
31
33
  () => args.value,
32
34
  (v) => {
33
35
  innerValue.value = v
34
- },
36
+ }
35
37
  )
36
38
  const onSliderChange = (v) => {
37
39
  innerValue.value = v
@@ -0,0 +1,88 @@
1
+ /* eslint-disable */
2
+ import { mount } from '@vue/test-utils'
3
+ import SwitchField from '@/components/inputs/switchField'
4
+ import RCIcon from '@/components/icon'
5
+ import InfoText from '@/components/infoText'
6
+
7
+ // import.meta in the real iconCache.mjs cannot be parsed by Jest.
8
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
9
+ fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
10
+ }))
11
+
12
+ const OPTIONS = [
13
+ { value: 0, content: 'Off', dataId: 'opt_off' },
14
+ { value: 1, content: 'Auto', dataId: 'opt_auto' },
15
+ { value: 2, content: 'On', dataId: 'opt_on' },
16
+ ]
17
+
18
+ describe('SwitchField', () => {
19
+ const mountSwitch = (props = {}) =>
20
+ mount(SwitchField, { props: { options: OPTIONS, value: 1, ...props } })
21
+
22
+ it('renders one option per entry with its content', () => {
23
+ const wrapper = mountSwitch()
24
+ expect(wrapper.find('[data-id="opt_off"]').exists()).toBe(true)
25
+ expect(wrapper.find('[data-id="opt_auto"]').exists()).toBe(true)
26
+ expect(wrapper.find('[data-id="opt_on"]').exists()).toBe(true)
27
+ expect(wrapper.text()).toContain('Off')
28
+ expect(wrapper.text()).toContain('Auto')
29
+ expect(wrapper.text()).toContain('On')
30
+ })
31
+
32
+ it('emits on-switch-change with the clicked value', async () => {
33
+ const wrapper = mountSwitch()
34
+
35
+ await wrapper.find('[data-id="opt_on"]').trigger('click')
36
+
37
+ expect(wrapper.emitted('on-switch-change')).toHaveLength(1)
38
+ expect(wrapper.emitted('on-switch-change')[0]).toEqual([2])
39
+ })
40
+
41
+ it('emits the matching value for each option clicked', async () => {
42
+ const wrapper = mountSwitch()
43
+
44
+ await wrapper.find('[data-id="opt_off"]').trigger('click')
45
+ await wrapper.find('[data-id="opt_auto"]').trigger('click')
46
+
47
+ const emitted = wrapper.emitted('on-switch-change')
48
+ expect(emitted).toHaveLength(2)
49
+ expect(emitted[0]).toEqual([0])
50
+ expect(emitted[1]).toEqual([1])
51
+ })
52
+
53
+ it('does not emit when disabled', async () => {
54
+ const wrapper = mountSwitch({ disabled: true })
55
+
56
+ await wrapper.find('[data-id="opt_on"]').trigger('click')
57
+
58
+ expect(wrapper.emitted('on-switch-change')).toBeUndefined()
59
+ })
60
+
61
+ it('renders the label with the required star', () => {
62
+ const wrapper = mountSwitch({ label: 'Mode', isRequiredLabel: true })
63
+ expect(wrapper.text()).toContain('Mode')
64
+ expect(wrapper.text()).toContain('*')
65
+ })
66
+
67
+ it('renders the info text only when a message is provided', () => {
68
+ expect(mountSwitch({ label: 'Mode' }).findComponent(InfoText).exists()).toBe(
69
+ false
70
+ )
71
+ expect(
72
+ mountSwitch({ label: 'Mode', infoTextMessage: 'Helpful hint' })
73
+ .findComponent(InfoText)
74
+ .exists()
75
+ ).toBe(true)
76
+ })
77
+
78
+ it('renders an icon only for options that define one', () => {
79
+ const wrapper = mountSwitch({
80
+ options: [
81
+ { value: 0, content: 'A', icon: 'check', iconSize: '12px' },
82
+ { value: 1, content: 'B' },
83
+ ],
84
+ value: 0,
85
+ })
86
+ expect(wrapper.findAllComponents(RCIcon)).toHaveLength(1)
87
+ })
88
+ })