@gitlab/ui 78.18.0 → 79.0.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/CHANGELOG.md +29 -0
- package/dist/components/base/form/form_textarea/form_textarea.js +55 -1
- package/dist/components/base/new_dropdowns/base_dropdown/base_dropdown.js +4 -3
- package/dist/components/base/new_dropdowns/constants.js +2 -1
- package/dist/tokens/css/tokens.css +1 -1
- package/dist/tokens/css/tokens.dark.css +1 -1
- package/dist/tokens/js/tokens.dark.js +1 -1
- package/dist/tokens/js/tokens.js +1 -1
- package/dist/tokens/scss/_tokens.dark.scss +1 -1
- package/dist/tokens/scss/_tokens.scss +1 -1
- package/dist/utils/story_decorators/container.js +10 -7
- package/package.json +3 -3
- package/src/components/base/form/form_textarea/form_textarea.spec.js +88 -5
- package/src/components/base/form/form_textarea/form_textarea.stories.js +30 -5
- package/src/components/base/form/form_textarea/form_textarea.vue +99 -4
- package/src/components/base/new_dropdowns/base_dropdown/base_dropdown.spec.js +52 -12
- package/src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue +4 -1
- package/src/components/base/new_dropdowns/constants.js +1 -0
- package/src/components/base/new_dropdowns/disclosure/disclosure_dropdown.stories.js +28 -0
- package/src/utils/story_decorators/container.js +7 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
# [79.0.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.19.0...v79.0.0) (2024-04-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **BaseDropdown:** Use a "main" element boundary when available ([ee0cd82](https://gitlab.com/gitlab-org/gitlab-ui/commit/ee0cd82b8b2ae6986052877250884ce9cd43df21))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
* **BaseDropdown:** This change slightly alters the default positioning
|
|
12
|
+
of GlDisclosureDropdown and GlListbox.
|
|
13
|
+
|
|
14
|
+
If the dropdown is placed inside a <main> element: The disclosure
|
|
15
|
+
will use main as a boundary to not overflow it. This change aims
|
|
16
|
+
to smartly reduce the need to use custom "placement" ('right'
|
|
17
|
+
and 'bottom-end') options, especially when the dropdown is placed
|
|
18
|
+
on the right side, such as in contextual menus.
|
|
19
|
+
|
|
20
|
+
If the dropdown is not placed in a <main> element, behavior does
|
|
21
|
+
change.
|
|
22
|
+
|
|
23
|
+
# [78.19.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.18.0...v78.19.0) (2024-04-22)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* **GlFormTextarea:** add support for character count ([1b359aa](https://gitlab.com/gitlab-org/gitlab-ui/commit/1b359aabb514831bddaeca108132427ecde4ae64))
|
|
29
|
+
|
|
1
30
|
# [78.18.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.17.0...v78.18.0) (2024-04-19)
|
|
2
31
|
|
|
3
32
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { BFormTextarea } from 'bootstrap-vue/esm/index.js';
|
|
2
|
+
import debounce from 'lodash/debounce';
|
|
3
|
+
import uniqueId from 'lodash/uniqueId';
|
|
2
4
|
import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
|
|
3
5
|
|
|
4
6
|
const model = {
|
|
@@ -29,8 +31,23 @@ var script = {
|
|
|
29
31
|
type: Boolean,
|
|
30
32
|
required: false,
|
|
31
33
|
default: false
|
|
34
|
+
},
|
|
35
|
+
/**
|
|
36
|
+
* Max character count for the textarea.
|
|
37
|
+
*/
|
|
38
|
+
characterCount: {
|
|
39
|
+
type: Number,
|
|
40
|
+
required: false,
|
|
41
|
+
default: null
|
|
32
42
|
}
|
|
33
43
|
},
|
|
44
|
+
data() {
|
|
45
|
+
return {
|
|
46
|
+
characterCountId: uniqueId('form-textarea-character-count-'),
|
|
47
|
+
remainingCharacterCount: this.initialRemainingCharacterCount(),
|
|
48
|
+
remainingCharacterCountSrOnly: this.initialRemainingCharacterCount()
|
|
49
|
+
};
|
|
50
|
+
},
|
|
34
51
|
computed: {
|
|
35
52
|
listeners() {
|
|
36
53
|
var _this = this;
|
|
@@ -62,13 +79,50 @@ var script = {
|
|
|
62
79
|
},
|
|
63
80
|
keypressEvent() {
|
|
64
81
|
return this.submitOnEnter ? 'keyup' : null;
|
|
82
|
+
},
|
|
83
|
+
isCharacterCountOverLimit() {
|
|
84
|
+
return this.remainingCharacterCount < 0;
|
|
85
|
+
},
|
|
86
|
+
characterCountTextClass() {
|
|
87
|
+
return this.isCharacterCountOverLimit ? 'gl-text-red-500' : 'gl-text-gray-500';
|
|
88
|
+
},
|
|
89
|
+
showCharacterCount() {
|
|
90
|
+
return this.characterCount !== null;
|
|
91
|
+
},
|
|
92
|
+
bFormTextareaProps() {
|
|
93
|
+
return {
|
|
94
|
+
...this.$attrs,
|
|
95
|
+
class: 'gl-form-input gl-form-textarea',
|
|
96
|
+
noResize: this.noResize,
|
|
97
|
+
value: this.value
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
watch: {
|
|
102
|
+
value(newValue) {
|
|
103
|
+
if (!this.showCharacterCount) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.remainingCharacterCount = this.characterCount - newValue.length;
|
|
107
|
+
this.debouncedUpdateRemainingCharacterCountSrOnly(newValue);
|
|
65
108
|
}
|
|
66
109
|
},
|
|
110
|
+
created() {
|
|
111
|
+
// Debounce updating the remaining character count for a second so
|
|
112
|
+
// screen readers announce the remaining text after the text in the textarea.
|
|
113
|
+
this.debouncedUpdateRemainingCharacterCountSrOnly = debounce(this.updateRemainingCharacterCountSrOnly, 1000);
|
|
114
|
+
},
|
|
67
115
|
methods: {
|
|
68
116
|
handleKeyPress(e) {
|
|
69
117
|
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
|
|
70
118
|
this.$emit('submit');
|
|
71
119
|
}
|
|
120
|
+
},
|
|
121
|
+
updateRemainingCharacterCountSrOnly(newValue) {
|
|
122
|
+
this.remainingCharacterCountSrOnly = this.characterCount - newValue.length;
|
|
123
|
+
},
|
|
124
|
+
initialRemainingCharacterCount() {
|
|
125
|
+
return this.characterCount - this.value.length;
|
|
72
126
|
}
|
|
73
127
|
}
|
|
74
128
|
};
|
|
@@ -77,7 +131,7 @@ var script = {
|
|
|
77
131
|
const __vue_script__ = script;
|
|
78
132
|
|
|
79
133
|
/* template */
|
|
80
|
-
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('b-form-textarea',_vm._g(_vm._b({
|
|
134
|
+
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return (_vm.showCharacterCount)?_c('div',[_c('b-form-textarea',_vm._g(_vm._b({attrs:{"aria-describedby":_vm.characterCountId},nativeOn:_vm._d({},[_vm.keypressEvent,function($event){return _vm.handleKeyPress.apply(null, arguments)}])},'b-form-textarea',_vm.bFormTextareaProps,false),_vm.listeners)),_vm._v(" "),_c('small',{class:['form-text', _vm.characterCountTextClass],attrs:{"aria-hidden":"true"}},[(_vm.isCharacterCountOverLimit)?_vm._t("character-count-over-limit-text",null,{"count":Math.abs(_vm.remainingCharacterCount)}):_vm._t("character-count-text",null,{"count":_vm.remainingCharacterCount})],2),_vm._v(" "),_c('div',{staticClass:"gl-sr-only",attrs:{"id":_vm.characterCountId,"aria-live":"polite","data-testid":"character-count-text-sr-only"}},[(_vm.isCharacterCountOverLimit)?_vm._t("character-count-over-limit-text",null,{"count":Math.abs(_vm.remainingCharacterCount)}):_vm._t("character-count-text",null,{"count":_vm.remainingCharacterCountSrOnly})],2)],1):_c('b-form-textarea',_vm._g(_vm._b({nativeOn:_vm._d({},[_vm.keypressEvent,function($event){return _vm.handleKeyPress.apply(null, arguments)}])},'b-form-textarea',_vm.bFormTextareaProps,false),_vm.listeners))};
|
|
81
135
|
var __vue_staticRenderFns__ = [];
|
|
82
136
|
|
|
83
137
|
/* style */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import uniqueId from 'lodash/uniqueId';
|
|
2
|
-
import { offset, autoPlacement, size, autoUpdate, computePosition } from '@floating-ui/dom';
|
|
2
|
+
import { offset, autoPlacement, shift, size, autoUpdate, computePosition } from '@floating-ui/dom';
|
|
3
3
|
import { buttonCategoryOptions, dropdownVariantOptions, buttonSizeOptions, dropdownPlacements, dropdownAllowedAutoPlacements } from '../../../../utils/constants';
|
|
4
|
-
import { POSITION_ABSOLUTE, POSITION_FIXED, GL_DROPDOWN_CONTENTS_CLASS, GL_DROPDOWN_BEFORE_CLOSE, GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, ENTER, SPACE, ARROW_DOWN, GL_DROPDOWN_FOCUS_CONTENT } from '../constants';
|
|
4
|
+
import { POSITION_ABSOLUTE, POSITION_FIXED, GL_DROPDOWN_BOUNDARY_SELECTOR, GL_DROPDOWN_CONTENTS_CLASS, GL_DROPDOWN_BEFORE_CLOSE, GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, ENTER, SPACE, ARROW_DOWN, GL_DROPDOWN_FOCUS_CONTENT } from '../constants';
|
|
5
5
|
import { logWarning, isElementFocusable, isElementTabbable } from '../../../../utils/utils';
|
|
6
6
|
import GlButton from '../../button/button';
|
|
7
7
|
import GlIcon from '../../icon/icon';
|
|
@@ -252,8 +252,9 @@ var script = {
|
|
|
252
252
|
strategy: this.positioningStrategy,
|
|
253
253
|
middleware: [offset(this.offset), autoPlacement({
|
|
254
254
|
alignment,
|
|
255
|
+
boundary: this.$el.closest(GL_DROPDOWN_BOUNDARY_SELECTOR) || 'clippingAncestors',
|
|
255
256
|
allowedPlacements: dropdownAllowedAutoPlacements[this.placement]
|
|
256
|
-
}), size({
|
|
257
|
+
}), shift(), size({
|
|
257
258
|
apply: _ref => {
|
|
258
259
|
var _this$nonScrollableCo;
|
|
259
260
|
let {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// base dropdown events
|
|
2
|
+
const GL_DROPDOWN_BOUNDARY_SELECTOR = 'main';
|
|
2
3
|
const GL_DROPDOWN_SHOWN = 'shown';
|
|
3
4
|
const GL_DROPDOWN_HIDDEN = 'hidden';
|
|
4
5
|
const GL_DROPDOWN_BEFORE_CLOSE = 'beforeClose';
|
|
@@ -18,4 +19,4 @@ const POSITION_ABSOLUTE = 'absolute';
|
|
|
18
19
|
const POSITION_FIXED = 'fixed';
|
|
19
20
|
const GL_DROPDOWN_CONTENTS_CLASS = 'gl-new-dropdown-contents';
|
|
20
21
|
|
|
21
|
-
export { ARROW_DOWN, ARROW_UP, END, ENTER, GL_DROPDOWN_BEFORE_CLOSE, GL_DROPDOWN_CONTENTS_CLASS, GL_DROPDOWN_FOCUS_CONTENT, GL_DROPDOWN_HIDDEN, GL_DROPDOWN_SHOWN, HOME, POSITION_ABSOLUTE, POSITION_FIXED, SPACE };
|
|
22
|
+
export { ARROW_DOWN, ARROW_UP, END, ENTER, GL_DROPDOWN_BEFORE_CLOSE, GL_DROPDOWN_BOUNDARY_SELECTOR, GL_DROPDOWN_CONTENTS_CLASS, GL_DROPDOWN_FOCUS_CONTENT, GL_DROPDOWN_HIDDEN, GL_DROPDOWN_SHOWN, HOME, POSITION_ABSOLUTE, POSITION_FIXED, SPACE };
|
package/dist/tokens/js/tokens.js
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* @param {object} style The style attribute to apply to the container.
|
|
6
6
|
* @return {function} The story decorator.
|
|
7
7
|
*/
|
|
8
|
-
const makeContainer = style
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
});
|
|
8
|
+
const makeContainer = function (style) {
|
|
9
|
+
let tag = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'div';
|
|
10
|
+
return Story => ({
|
|
11
|
+
render(h) {
|
|
12
|
+
return h(tag, {
|
|
13
|
+
style
|
|
14
|
+
}, [h(Story())]);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
};
|
|
15
18
|
|
|
16
19
|
export { makeContainer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gitlab/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "79.0.0",
|
|
4
4
|
"description": "GitLab UI Components",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -131,14 +131,14 @@
|
|
|
131
131
|
"babel-jest": "29.0.1",
|
|
132
132
|
"babel-loader": "^8.0.5",
|
|
133
133
|
"bootstrap": "4.6.2",
|
|
134
|
-
"cypress": "13.
|
|
134
|
+
"cypress": "13.8.0",
|
|
135
135
|
"cypress-axe": "^1.4.0",
|
|
136
136
|
"cypress-real-events": "^1.11.0",
|
|
137
137
|
"dompurify": "^3.0.0",
|
|
138
138
|
"emoji-regex": "^10.0.0",
|
|
139
139
|
"eslint": "8.57.0",
|
|
140
140
|
"eslint-import-resolver-jest": "3.0.2",
|
|
141
|
-
"eslint-plugin-cypress": "2.15.
|
|
141
|
+
"eslint-plugin-cypress": "2.15.2",
|
|
142
142
|
"eslint-plugin-storybook": "0.8.0",
|
|
143
143
|
"glob": "10.3.3",
|
|
144
144
|
"identity-obj-proxy": "^3.0.0",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { mount } from '@vue/test-utils';
|
|
2
|
+
import lodashDebounce from 'lodash/debounce';
|
|
2
3
|
import GlFormTextarea from './form_textarea.vue';
|
|
3
4
|
|
|
5
|
+
jest.mock('lodash/debounce', () => jest.fn((fn) => fn));
|
|
6
|
+
|
|
4
7
|
const modelEvent = GlFormTextarea.model.event;
|
|
5
8
|
const newValue = 'foo';
|
|
6
9
|
|
|
@@ -10,6 +13,25 @@ describe('GlFormTextArea', () => {
|
|
|
10
13
|
const createComponent = (propsData = {}) => {
|
|
11
14
|
wrapper = mount(GlFormTextarea, {
|
|
12
15
|
propsData,
|
|
16
|
+
scopedSlots: {
|
|
17
|
+
'character-count-text': function characterCountText({ count }) {
|
|
18
|
+
return count === 1 ? `${count} character remaining` : `${count} characters remaining`;
|
|
19
|
+
},
|
|
20
|
+
'character-count-over-limit-text': function characterCountOverLimitText({ count }) {
|
|
21
|
+
return count === 1 ? `${count} character over limit` : `${count} characters over limit`;
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const findTextarea = () => wrapper.find('textarea');
|
|
28
|
+
|
|
29
|
+
const itUpdatesDebouncedScreenReaderText = (expectedText) => {
|
|
30
|
+
it('updates debounced screen reader text', () => {
|
|
31
|
+
expect(lodashDebounce).toHaveBeenCalledWith(expect.any(Function), 1000);
|
|
32
|
+
expect(wrapper.find('[data-testid="character-count-text-sr-only"]').text()).toBe(
|
|
33
|
+
expectedText
|
|
34
|
+
);
|
|
13
35
|
});
|
|
14
36
|
};
|
|
15
37
|
|
|
@@ -20,7 +42,7 @@ describe('GlFormTextArea', () => {
|
|
|
20
42
|
});
|
|
21
43
|
|
|
22
44
|
it(`sets the textarea's value`, () => {
|
|
23
|
-
expect(
|
|
45
|
+
expect(findTextarea().element.value).toBe('initial');
|
|
24
46
|
});
|
|
25
47
|
|
|
26
48
|
describe('when the value prop changes', () => {
|
|
@@ -30,7 +52,7 @@ describe('GlFormTextArea', () => {
|
|
|
30
52
|
});
|
|
31
53
|
|
|
32
54
|
it(`updates the textarea's value`, () => {
|
|
33
|
-
expect(
|
|
55
|
+
expect(findTextarea().element.value).toBe(newValue);
|
|
34
56
|
});
|
|
35
57
|
});
|
|
36
58
|
});
|
|
@@ -39,7 +61,7 @@ describe('GlFormTextArea', () => {
|
|
|
39
61
|
beforeEach(() => {
|
|
40
62
|
createComponent();
|
|
41
63
|
|
|
42
|
-
|
|
64
|
+
findTextarea().setValue(newValue);
|
|
43
65
|
});
|
|
44
66
|
|
|
45
67
|
it('synchronously emits update event', () => {
|
|
@@ -59,7 +81,7 @@ describe('GlFormTextArea', () => {
|
|
|
59
81
|
|
|
60
82
|
createComponent({ debounce });
|
|
61
83
|
|
|
62
|
-
|
|
84
|
+
findTextarea().setValue(newValue);
|
|
63
85
|
});
|
|
64
86
|
|
|
65
87
|
it('synchronously emits an update event', () => {
|
|
@@ -82,7 +104,7 @@ describe('GlFormTextArea', () => {
|
|
|
82
104
|
beforeEach(() => {
|
|
83
105
|
createComponent({ lazy: true });
|
|
84
106
|
|
|
85
|
-
|
|
107
|
+
findTextarea().setValue(newValue);
|
|
86
108
|
});
|
|
87
109
|
|
|
88
110
|
it('synchronously emits an update event', () => {
|
|
@@ -119,4 +141,65 @@ describe('GlFormTextArea', () => {
|
|
|
119
141
|
expect(wrapper.emitted('submit')).toEqual([[]]);
|
|
120
142
|
});
|
|
121
143
|
});
|
|
144
|
+
|
|
145
|
+
describe('when `characterCount` prop is set', () => {
|
|
146
|
+
const characterCount = 10;
|
|
147
|
+
|
|
148
|
+
describe('when textarea character count is under the max character count', () => {
|
|
149
|
+
const textareaCharacterCount = 5;
|
|
150
|
+
const expectedText = `${characterCount - textareaCharacterCount} characters remaining`;
|
|
151
|
+
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
createComponent({
|
|
154
|
+
value: 'a'.repeat(textareaCharacterCount),
|
|
155
|
+
characterCount,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('displays remaining characters', () => {
|
|
160
|
+
expect(wrapper.text()).toContain(expectedText);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
itUpdatesDebouncedScreenReaderText(expectedText);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('when textarea character count is over the max character count', () => {
|
|
167
|
+
const textareaCharacterCount = 15;
|
|
168
|
+
const expectedText = `${textareaCharacterCount - characterCount} characters over limit`;
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
createComponent({
|
|
172
|
+
value: 'a'.repeat(textareaCharacterCount),
|
|
173
|
+
characterCount,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('displays number of characters over', () => {
|
|
178
|
+
expect(wrapper.text()).toContain(expectedText);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
itUpdatesDebouncedScreenReaderText(expectedText);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('when textarea value is updated', () => {
|
|
185
|
+
const textareaCharacterCount = 5;
|
|
186
|
+
const newTextareaCharacterCount = textareaCharacterCount + 3;
|
|
187
|
+
const expectedText = `${characterCount - newTextareaCharacterCount} characters remaining`;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
createComponent({
|
|
191
|
+
value: 'a'.repeat(textareaCharacterCount),
|
|
192
|
+
characterCount,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
wrapper.setProps({ value: 'a'.repeat(newTextareaCharacterCount) });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('updates character count text', () => {
|
|
199
|
+
expect(wrapper.text()).toContain(expectedText);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
itUpdatesDebouncedScreenReaderText(expectedText);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
122
205
|
});
|
|
@@ -3,32 +3,57 @@ import readme from './form_textarea.md';
|
|
|
3
3
|
|
|
4
4
|
const template = `
|
|
5
5
|
<gl-form-textarea
|
|
6
|
-
|
|
6
|
+
:value="value"
|
|
7
7
|
:placeholder="placeholder"
|
|
8
8
|
:rows="5"
|
|
9
9
|
:no-resize="noResize"
|
|
10
|
-
|
|
10
|
+
:character-count="characterCount"
|
|
11
|
+
@input="onInput"
|
|
12
|
+
>
|
|
13
|
+
<template #character-count-text="{ count }">{{ characterCountText(count) }}</template>
|
|
14
|
+
<template #character-count-over-limit-text="{ count }">{{ characterCountOverLimitText(count) }}</template>
|
|
15
|
+
</gl-form-textarea>
|
|
11
16
|
`;
|
|
12
17
|
|
|
13
18
|
const generateProps = ({
|
|
14
|
-
|
|
19
|
+
value = 'We take inspiration from other companies, and we always go for the boring solutions. Just like the rest of our work, we continually adjust our values and strive always to make them better. We used to have more values, but it was difficult to remember them all, so we condensed them and gave sub-values and created an acronym. Everyone is welcome to suggest improvements.',
|
|
15
20
|
placeholder = 'hello',
|
|
16
21
|
noResize = GlFormTextarea.props.noResize.default,
|
|
22
|
+
characterCount = null,
|
|
17
23
|
} = {}) => ({
|
|
18
|
-
|
|
24
|
+
value,
|
|
19
25
|
placeholder,
|
|
20
26
|
noResize,
|
|
27
|
+
characterCount,
|
|
21
28
|
});
|
|
22
29
|
|
|
23
|
-
const Template = (args) => ({
|
|
30
|
+
const Template = (args, { updateArgs }) => ({
|
|
24
31
|
components: { GlFormTextarea },
|
|
25
32
|
props: Object.keys(args),
|
|
33
|
+
methods: {
|
|
34
|
+
onInput(value) {
|
|
35
|
+
updateArgs({ ...args, value });
|
|
36
|
+
},
|
|
37
|
+
characterCountText(count) {
|
|
38
|
+
return count === 1 ? `${count} character remaining` : `${count} characters remaining`;
|
|
39
|
+
},
|
|
40
|
+
characterCountOverLimitText(count) {
|
|
41
|
+
return count === 1 ? `${count} character over limit` : `${count} characters over limit`;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
26
44
|
template,
|
|
27
45
|
});
|
|
28
46
|
|
|
29
47
|
export const Default = Template.bind({});
|
|
30
48
|
Default.args = generateProps();
|
|
31
49
|
|
|
50
|
+
export const WithCharacterCount = Template.bind({});
|
|
51
|
+
WithCharacterCount.args = generateProps({
|
|
52
|
+
value: '',
|
|
53
|
+
placeholder: 'hello',
|
|
54
|
+
characterCount: 100,
|
|
55
|
+
});
|
|
56
|
+
|
|
32
57
|
export default {
|
|
33
58
|
title: 'base/form/form-textarea',
|
|
34
59
|
component: GlFormTextarea,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { BFormTextarea } from 'bootstrap-vue';
|
|
3
|
+
import debounce from 'lodash/debounce';
|
|
4
|
+
import uniqueId from 'lodash/uniqueId';
|
|
3
5
|
|
|
4
6
|
const model = {
|
|
5
7
|
prop: 'value',
|
|
@@ -31,6 +33,21 @@ export default {
|
|
|
31
33
|
required: false,
|
|
32
34
|
default: false,
|
|
33
35
|
},
|
|
36
|
+
/**
|
|
37
|
+
* Max character count for the textarea.
|
|
38
|
+
*/
|
|
39
|
+
characterCount: {
|
|
40
|
+
type: Number,
|
|
41
|
+
required: false,
|
|
42
|
+
default: null,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
data() {
|
|
46
|
+
return {
|
|
47
|
+
characterCountId: uniqueId('form-textarea-character-count-'),
|
|
48
|
+
remainingCharacterCount: this.initialRemainingCharacterCount(),
|
|
49
|
+
remainingCharacterCountSrOnly: this.initialRemainingCharacterCount(),
|
|
50
|
+
};
|
|
34
51
|
},
|
|
35
52
|
computed: {
|
|
36
53
|
listeners() {
|
|
@@ -57,6 +74,41 @@ export default {
|
|
|
57
74
|
keypressEvent() {
|
|
58
75
|
return this.submitOnEnter ? 'keyup' : null;
|
|
59
76
|
},
|
|
77
|
+
isCharacterCountOverLimit() {
|
|
78
|
+
return this.remainingCharacterCount < 0;
|
|
79
|
+
},
|
|
80
|
+
characterCountTextClass() {
|
|
81
|
+
return this.isCharacterCountOverLimit ? 'gl-text-red-500' : 'gl-text-gray-500';
|
|
82
|
+
},
|
|
83
|
+
showCharacterCount() {
|
|
84
|
+
return this.characterCount !== null;
|
|
85
|
+
},
|
|
86
|
+
bFormTextareaProps() {
|
|
87
|
+
return {
|
|
88
|
+
...this.$attrs,
|
|
89
|
+
class: 'gl-form-input gl-form-textarea',
|
|
90
|
+
noResize: this.noResize,
|
|
91
|
+
value: this.value,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
watch: {
|
|
96
|
+
value(newValue) {
|
|
97
|
+
if (!this.showCharacterCount) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.remainingCharacterCount = this.characterCount - newValue.length;
|
|
102
|
+
this.debouncedUpdateRemainingCharacterCountSrOnly(newValue);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
created() {
|
|
106
|
+
// Debounce updating the remaining character count for a second so
|
|
107
|
+
// screen readers announce the remaining text after the text in the textarea.
|
|
108
|
+
this.debouncedUpdateRemainingCharacterCountSrOnly = debounce(
|
|
109
|
+
this.updateRemainingCharacterCountSrOnly,
|
|
110
|
+
1000
|
|
111
|
+
);
|
|
60
112
|
},
|
|
61
113
|
methods: {
|
|
62
114
|
handleKeyPress(e) {
|
|
@@ -64,16 +116,59 @@ export default {
|
|
|
64
116
|
this.$emit('submit');
|
|
65
117
|
}
|
|
66
118
|
},
|
|
119
|
+
updateRemainingCharacterCountSrOnly(newValue) {
|
|
120
|
+
this.remainingCharacterCountSrOnly = this.characterCount - newValue.length;
|
|
121
|
+
},
|
|
122
|
+
initialRemainingCharacterCount() {
|
|
123
|
+
return this.characterCount - this.value.length;
|
|
124
|
+
},
|
|
67
125
|
},
|
|
68
126
|
};
|
|
69
127
|
</script>
|
|
70
128
|
|
|
71
129
|
<template>
|
|
130
|
+
<div v-if="showCharacterCount">
|
|
131
|
+
<b-form-textarea
|
|
132
|
+
:aria-describedby="characterCountId"
|
|
133
|
+
v-bind="bFormTextareaProps"
|
|
134
|
+
v-on="listeners"
|
|
135
|
+
@[keypressEvent].native="handleKeyPress"
|
|
136
|
+
/>
|
|
137
|
+
<small :class="['form-text', characterCountTextClass]" aria-hidden="true">
|
|
138
|
+
<!--
|
|
139
|
+
@slot Internationalized over character count text. Example: `<template #character-count-over-limit-text="{ count }">{{ n__('%d character over limit', '%d characters over limit', count) }}</template>`
|
|
140
|
+
@binding {number} count
|
|
141
|
+
-->
|
|
142
|
+
<slot
|
|
143
|
+
v-if="isCharacterCountOverLimit"
|
|
144
|
+
name="character-count-over-limit-text"
|
|
145
|
+
:count="Math.abs(remainingCharacterCount)"
|
|
146
|
+
></slot>
|
|
147
|
+
<!--
|
|
148
|
+
@slot Internationalized character count text. Example: `<template #character-count-text="{ count }">{{ n__('%d character remaining', '%d characters remaining', count) }}</template>`
|
|
149
|
+
@binding {number} count
|
|
150
|
+
-->
|
|
151
|
+
|
|
152
|
+
<slot v-else name="character-count-text" :count="remainingCharacterCount"></slot>
|
|
153
|
+
</small>
|
|
154
|
+
<div
|
|
155
|
+
:id="characterCountId"
|
|
156
|
+
class="gl-sr-only"
|
|
157
|
+
aria-live="polite"
|
|
158
|
+
data-testid="character-count-text-sr-only"
|
|
159
|
+
>
|
|
160
|
+
<slot
|
|
161
|
+
v-if="isCharacterCountOverLimit"
|
|
162
|
+
name="character-count-over-limit-text"
|
|
163
|
+
:count="Math.abs(remainingCharacterCount)"
|
|
164
|
+
></slot>
|
|
165
|
+
|
|
166
|
+
<slot v-else name="character-count-text" :count="remainingCharacterCountSrOnly"></slot>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
72
169
|
<b-form-textarea
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
v-bind="$attrs"
|
|
76
|
-
:value="value"
|
|
170
|
+
v-else
|
|
171
|
+
v-bind="bFormTextareaProps"
|
|
77
172
|
v-on="listeners"
|
|
78
173
|
@[keypressEvent].native="handleKeyPress"
|
|
79
174
|
/>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mount } from '@vue/test-utils';
|
|
2
2
|
import { nextTick } from 'vue';
|
|
3
|
-
import { computePosition, autoUpdate, offset, autoPlacement } from '@floating-ui/dom';
|
|
3
|
+
import { computePosition, autoUpdate, offset, autoPlacement, shift } from '@floating-ui/dom';
|
|
4
4
|
import {
|
|
5
5
|
ARROW_DOWN,
|
|
6
6
|
GL_DROPDOWN_FOCUS_CONTENT,
|
|
@@ -15,8 +15,9 @@ import GlBaseDropdown from './base_dropdown.vue';
|
|
|
15
15
|
|
|
16
16
|
jest.mock('@floating-ui/dom');
|
|
17
17
|
const mockStopAutoUpdate = jest.fn();
|
|
18
|
-
offset.mockImplementation((
|
|
19
|
-
autoPlacement.mockImplementation((
|
|
18
|
+
offset.mockImplementation((offsetOpts = {}) => ({ offsetOpts }));
|
|
19
|
+
autoPlacement.mockImplementation((autoPlacementOpts = {}) => ({ autoPlacementOpts }));
|
|
20
|
+
shift.mockImplementation((shiftOpts = {}) => ({ shiftOpts }));
|
|
20
21
|
|
|
21
22
|
const DEFAULT_BTN_TOGGLE_CLASSES = [
|
|
22
23
|
'btn',
|
|
@@ -29,7 +30,7 @@ const DEFAULT_BTN_TOGGLE_CLASSES = [
|
|
|
29
30
|
describe('base dropdown', () => {
|
|
30
31
|
let wrapper;
|
|
31
32
|
|
|
32
|
-
const buildWrapper = (propsData, slots = {},
|
|
33
|
+
const buildWrapper = (propsData, { slots = {}, ...options } = {}) => {
|
|
33
34
|
wrapper = mount(GlBaseDropdown, {
|
|
34
35
|
propsData: {
|
|
35
36
|
toggleId: 'dropdown-toggle-btn-1',
|
|
@@ -40,7 +41,7 @@ describe('base dropdown', () => {
|
|
|
40
41
|
...slots,
|
|
41
42
|
},
|
|
42
43
|
attachTo: document.body,
|
|
43
|
-
|
|
44
|
+
...options,
|
|
44
45
|
});
|
|
45
46
|
};
|
|
46
47
|
|
|
@@ -99,6 +100,35 @@ describe('base dropdown', () => {
|
|
|
99
100
|
autoUpdate.mockImplementation(jest.requireActual('@floating-ui/dom').autoUpdate);
|
|
100
101
|
});
|
|
101
102
|
|
|
103
|
+
it('initializes Floating UI with a default boundary', async () => {
|
|
104
|
+
document.body.innerHTML = '<main><div></div></main>';
|
|
105
|
+
|
|
106
|
+
buildWrapper(undefined, {
|
|
107
|
+
attachTo: document.querySelector('main div'),
|
|
108
|
+
});
|
|
109
|
+
await findDefaultDropdownToggle().trigger('click');
|
|
110
|
+
|
|
111
|
+
expect(computePosition).toHaveBeenCalledWith(
|
|
112
|
+
findDefaultDropdownToggle().element,
|
|
113
|
+
findDropdownMenu().element,
|
|
114
|
+
{
|
|
115
|
+
placement: 'bottom-start',
|
|
116
|
+
strategy: 'absolute',
|
|
117
|
+
middleware: [
|
|
118
|
+
offset({ mainAxis: DEFAULT_OFFSET }),
|
|
119
|
+
autoPlacement({
|
|
120
|
+
alignment: 'start',
|
|
121
|
+
boundary: document.querySelector('main'),
|
|
122
|
+
allowedPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
|
|
123
|
+
}),
|
|
124
|
+
shift(),
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
document.body.innerHTML = '';
|
|
130
|
+
});
|
|
131
|
+
|
|
102
132
|
it('initializes Floating UI with reference and floating elements and config for left-aligned menu', async () => {
|
|
103
133
|
buildWrapper();
|
|
104
134
|
await findDefaultDropdownToggle().trigger('click');
|
|
@@ -113,8 +143,10 @@ describe('base dropdown', () => {
|
|
|
113
143
|
offset({ mainAxis: DEFAULT_OFFSET }),
|
|
114
144
|
autoPlacement({
|
|
115
145
|
alignment: 'start',
|
|
146
|
+
boundary: 'clippingAncestors',
|
|
116
147
|
allowedPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
|
|
117
148
|
}),
|
|
149
|
+
shift(),
|
|
118
150
|
],
|
|
119
151
|
}
|
|
120
152
|
);
|
|
@@ -134,8 +166,10 @@ describe('base dropdown', () => {
|
|
|
134
166
|
offset({ mainAxis: DEFAULT_OFFSET }),
|
|
135
167
|
autoPlacement({
|
|
136
168
|
alignment: undefined,
|
|
169
|
+
boundary: 'clippingAncestors',
|
|
137
170
|
allowedPlacements: ['bottom', 'top'],
|
|
138
171
|
}),
|
|
172
|
+
shift(),
|
|
139
173
|
],
|
|
140
174
|
}
|
|
141
175
|
);
|
|
@@ -155,8 +189,10 @@ describe('base dropdown', () => {
|
|
|
155
189
|
offset({ mainAxis: DEFAULT_OFFSET }),
|
|
156
190
|
autoPlacement({
|
|
157
191
|
alignment: 'end',
|
|
192
|
+
boundary: 'clippingAncestors',
|
|
158
193
|
allowedPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
|
|
159
194
|
}),
|
|
195
|
+
shift(),
|
|
160
196
|
],
|
|
161
197
|
}
|
|
162
198
|
);
|
|
@@ -176,8 +212,10 @@ describe('base dropdown', () => {
|
|
|
176
212
|
offset({ mainAxis: DEFAULT_OFFSET }),
|
|
177
213
|
autoPlacement({
|
|
178
214
|
alignment: 'start',
|
|
215
|
+
boundary: 'clippingAncestors',
|
|
179
216
|
allowedPlacements: ['right-start', 'right-end', 'left-start', 'left-end'],
|
|
180
217
|
}),
|
|
218
|
+
shift(),
|
|
181
219
|
],
|
|
182
220
|
}
|
|
183
221
|
);
|
|
@@ -197,7 +235,7 @@ describe('base dropdown', () => {
|
|
|
197
235
|
{
|
|
198
236
|
placement: 'bottom-end',
|
|
199
237
|
strategy: 'absolute',
|
|
200
|
-
middleware: [offset(customOffset), autoPlacement(expect.any(Object))],
|
|
238
|
+
middleware: [offset(customOffset), autoPlacement(expect.any(Object)), shift()],
|
|
201
239
|
}
|
|
202
240
|
);
|
|
203
241
|
});
|
|
@@ -245,7 +283,7 @@ describe('base dropdown', () => {
|
|
|
245
283
|
const slots = { default: defaultContent };
|
|
246
284
|
|
|
247
285
|
it('renders the content', () => {
|
|
248
|
-
buildWrapper({}, slots);
|
|
286
|
+
buildWrapper({}, { slots });
|
|
249
287
|
expect(wrapper.find('.gl-new-dropdown-inner').html()).toContain(defaultContent);
|
|
250
288
|
});
|
|
251
289
|
});
|
|
@@ -409,10 +447,12 @@ describe('base dropdown', () => {
|
|
|
409
447
|
|
|
410
448
|
beforeEach(() => {
|
|
411
449
|
event = undefined;
|
|
412
|
-
buildWrapper(undefined,
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
450
|
+
buildWrapper(undefined, {
|
|
451
|
+
listeners: {
|
|
452
|
+
[GL_DROPDOWN_BEFORE_CLOSE]({ originalEvent, preventDefault }) {
|
|
453
|
+
event = originalEvent;
|
|
454
|
+
preventDefault();
|
|
455
|
+
},
|
|
416
456
|
},
|
|
417
457
|
});
|
|
418
458
|
});
|
|
@@ -467,7 +507,7 @@ describe('base dropdown', () => {
|
|
|
467
507
|
|
|
468
508
|
beforeEach(() => {
|
|
469
509
|
const slots = { toggle: toggleContent };
|
|
470
|
-
buildWrapper({}, slots);
|
|
510
|
+
buildWrapper({}, { slots });
|
|
471
511
|
});
|
|
472
512
|
|
|
473
513
|
it('does not render default toggle button', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import uniqueId from 'lodash/uniqueId';
|
|
3
|
-
import { computePosition, autoUpdate, offset, size, autoPlacement } from '@floating-ui/dom';
|
|
3
|
+
import { computePosition, autoUpdate, offset, size, autoPlacement, shift } from '@floating-ui/dom';
|
|
4
4
|
import {
|
|
5
5
|
buttonCategoryOptions,
|
|
6
6
|
buttonSizeOptions,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
dropdownVariantOptions,
|
|
10
10
|
} from '../../../../utils/constants';
|
|
11
11
|
import {
|
|
12
|
+
GL_DROPDOWN_BOUNDARY_SELECTOR,
|
|
12
13
|
GL_DROPDOWN_SHOWN,
|
|
13
14
|
GL_DROPDOWN_HIDDEN,
|
|
14
15
|
GL_DROPDOWN_BEFORE_CLOSE,
|
|
@@ -272,8 +273,10 @@ export default {
|
|
|
272
273
|
offset(this.offset),
|
|
273
274
|
autoPlacement({
|
|
274
275
|
alignment,
|
|
276
|
+
boundary: this.$el.closest(GL_DROPDOWN_BOUNDARY_SELECTOR) || 'clippingAncestors',
|
|
275
277
|
allowedPlacements: dropdownAllowedAutoPlacements[this.placement],
|
|
276
278
|
}),
|
|
279
|
+
shift(),
|
|
277
280
|
size({
|
|
278
281
|
apply: ({ availableHeight, elements }) => {
|
|
279
282
|
const contentsEl = elements.floating.querySelector(`.${GL_DROPDOWN_CONTENTS_CLASS}`);
|
|
@@ -343,3 +343,31 @@ export default {
|
|
|
343
343
|
startOpened: true,
|
|
344
344
|
},
|
|
345
345
|
};
|
|
346
|
+
|
|
347
|
+
export const InMainWrapper = (args, { argTypes }) => ({
|
|
348
|
+
toggleId: TOGGLE_ID,
|
|
349
|
+
props: Object.keys(argTypes),
|
|
350
|
+
components: {
|
|
351
|
+
GlDisclosureDropdown,
|
|
352
|
+
GlTooltip,
|
|
353
|
+
},
|
|
354
|
+
template: `
|
|
355
|
+
<div>
|
|
356
|
+
${template()}
|
|
357
|
+
<gl-tooltip :target="$options.toggleId" placement="right">
|
|
358
|
+
Automatic placement to stay inside <main> boundary
|
|
359
|
+
</gl-tooltip>
|
|
360
|
+
</div>
|
|
361
|
+
`,
|
|
362
|
+
});
|
|
363
|
+
InMainWrapper.args = {
|
|
364
|
+
items: mockItems,
|
|
365
|
+
icon: 'ellipsis_v',
|
|
366
|
+
noCaret: true,
|
|
367
|
+
toggleText: 'Disclosure',
|
|
368
|
+
textSrOnly: true,
|
|
369
|
+
toggleId: TOGGLE_ID,
|
|
370
|
+
};
|
|
371
|
+
InMainWrapper.decorators = [
|
|
372
|
+
makeContainer({ backgroundColor: '#efefef', textAlign: 'right', height: '200px' }, 'main'),
|
|
373
|
+
];
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* @param {object} style The style attribute to apply to the container.
|
|
6
6
|
* @return {function} The story decorator.
|
|
7
7
|
*/
|
|
8
|
-
export const makeContainer =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
});
|
|
8
|
+
export const makeContainer =
|
|
9
|
+
(style, tag = 'div') =>
|
|
10
|
+
(Story) => ({
|
|
11
|
+
render(h) {
|
|
12
|
+
return h(tag, { style }, [h(Story())]);
|
|
13
|
+
},
|
|
14
|
+
});
|