@bethinkpl/design-system 42.0.0 → 43.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/dist/design-system.css +1 -1
- package/dist/design-system.js +16798 -13006
- package/dist/design-system.js.map +1 -1
- package/dist/lib/js/components/Cards/Card/Card.consts.d.ts +14 -0
- package/dist/lib/js/components/Cards/Card/Card.vue.d.ts +10 -5
- package/dist/lib/js/components/Cards/CardExpandable/CardExpandable.vue.d.ts +33 -15
- package/dist/lib/js/components/Form/InputField/useInputFieldWithinForm.d.ts +1 -1
- package/dist/lib/js/components/Form/SelectField/SelectField.types.d.ts +26 -0
- package/dist/lib/js/components/Form/SelectField/SelectField.utils.d.ts +7 -0
- package/dist/lib/js/components/Form/SelectField/SelectField.vue.d.ts +107 -0
- package/dist/lib/js/components/Form/SelectField/SelectFieldOption.vue.d.ts +19 -0
- package/dist/lib/js/components/Form/SelectField/index.d.ts +4 -0
- package/dist/lib/js/components/Form/SelectField/useSelectFieldWithinForm.d.ts +8 -0
- package/dist/lib/js/components/Pagination/Pagination.vue.d.ts +2 -0
- package/dist/lib/js/components/SelectList/SelectListItem/SelectListItem.vue.d.ts +2 -0
- package/dist/lib/js/components/SelectList/SelectListItemToggle/SelectListItemToggle.vue.d.ts +2 -0
- package/dist/lib/js/components/SurveyQuestions/SurveyQuestionOpenEnded/SurveyQuestionOpenEnded.vue.d.ts +33 -15
- package/dist/lib/js/components/SurveyQuestions/SurveyQuestionScale/SurveyQuestionScale.vue.d.ts +33 -15
- package/dist/lib/js/components/Toast/Toast.vue.d.ts +33 -15
- package/dist/lib/js/composables/useFormFieldWithinForm.d.ts +3 -3
- package/dist/lib/js/index.d.ts +2 -0
- package/lib/js/components/Cards/Card/Card.consts.ts +18 -0
- package/lib/js/components/Cards/Card/Card.spec.ts +159 -33
- package/lib/js/components/Cards/Card/Card.stories.ts +21 -2
- package/lib/js/components/Cards/Card/Card.vue +90 -23
- package/lib/js/components/Dropdown/Dropdown.vue +6 -10
- package/lib/js/components/Form/Form.stories.ts +15 -1
- package/lib/js/components/Form/SelectField/SelectField.spec.ts +495 -0
- package/lib/js/components/Form/SelectField/SelectField.stories.ts +287 -0
- package/lib/js/components/Form/SelectField/SelectField.types.ts +65 -0
- package/lib/js/components/Form/SelectField/SelectField.utils.ts +83 -0
- package/lib/js/components/Form/SelectField/SelectField.vue +342 -0
- package/lib/js/components/Form/SelectField/SelectFieldOption.vue +51 -0
- package/lib/js/components/Form/SelectField/index.ts +5 -0
- package/lib/js/components/Form/SelectField/useSelectFieldWithinForm.ts +27 -0
- package/lib/js/components/SelectList/SelectListItem/SelectListItem.spec.ts +77 -0
- package/lib/js/components/SelectList/SelectListItem/SelectListItem.vue +4 -1
- package/lib/js/composables/useFormFieldWithinForm.ts +4 -4
- package/lib/js/index.ts +2 -0
- package/lib/js/styles/Shadows/Shadows.stories.scss +16 -0
- package/lib/js/styles/Shadows/Shadows.stories.ts +4 -0
- package/lib/styles/mixins/_dropdown-surface.scss +24 -0
- package/lib/styles/settings/_shadows.scss +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<form-field v-bind="formFieldProps">
|
|
3
|
+
<template #field="{ fieldId, messageId }">
|
|
4
|
+
<select-root
|
|
5
|
+
v-model="value"
|
|
6
|
+
:autocomplete="autocomplete"
|
|
7
|
+
:disabled="isDisabled"
|
|
8
|
+
:required="formFieldProps.hasRequiredIndicator"
|
|
9
|
+
@update:open="onOpenChange"
|
|
10
|
+
>
|
|
11
|
+
<select-trigger
|
|
12
|
+
:id="fieldId"
|
|
13
|
+
:aria-describedby="hasMessage ? messageId : undefined"
|
|
14
|
+
:aria-label="formFieldProps.label ? undefined : ariaLabel"
|
|
15
|
+
:class="[
|
|
16
|
+
'ds-selectField__trigger',
|
|
17
|
+
{
|
|
18
|
+
'-ds-error': formFieldProps.state === FORM_FIELD_STATES.ERROR,
|
|
19
|
+
'-ds-disabled': isDisabled,
|
|
20
|
+
},
|
|
21
|
+
]"
|
|
22
|
+
>
|
|
23
|
+
<ds-icon
|
|
24
|
+
v-if="leftIcon"
|
|
25
|
+
class="ds-selectField__leftIcon"
|
|
26
|
+
:icon="leftIcon"
|
|
27
|
+
:size="ICON_SIZES.X_SMALL"
|
|
28
|
+
/>
|
|
29
|
+
<select-value class="ds-selectField__value" :placeholder="placeholder" />
|
|
30
|
+
<select-icon as-child>
|
|
31
|
+
<ds-icon
|
|
32
|
+
class="ds-selectField__icon"
|
|
33
|
+
:icon="ICONS.FA_CHEVRON_DOWN"
|
|
34
|
+
:size="ICON_SIZES.X_SMALL"
|
|
35
|
+
/>
|
|
36
|
+
</select-icon>
|
|
37
|
+
</select-trigger>
|
|
38
|
+
|
|
39
|
+
<select-portal>
|
|
40
|
+
<select-content
|
|
41
|
+
class="ds-selectField__content"
|
|
42
|
+
position="popper"
|
|
43
|
+
align="start"
|
|
44
|
+
:side-offset="4"
|
|
45
|
+
:style="contentStyle"
|
|
46
|
+
>
|
|
47
|
+
<select-viewport as-child>
|
|
48
|
+
<ds-select-list>
|
|
49
|
+
<template
|
|
50
|
+
v-for="(group, groupIndex) in normalizedOptions"
|
|
51
|
+
:key="groupIndex"
|
|
52
|
+
>
|
|
53
|
+
<select-separator v-if="groupIndex > 0" as-child>
|
|
54
|
+
<ds-select-list-item-divider />
|
|
55
|
+
</select-separator>
|
|
56
|
+
|
|
57
|
+
<!-- A labelled group gets real `role="group"` semantics. -->
|
|
58
|
+
<select-group v-if="group.label">
|
|
59
|
+
<select-label as-child>
|
|
60
|
+
<ds-select-list-section-title
|
|
61
|
+
:label="group.label"
|
|
62
|
+
:is-uppercase="isGroupLabelUppercase"
|
|
63
|
+
/>
|
|
64
|
+
</select-label>
|
|
65
|
+
<select-field-option
|
|
66
|
+
v-for="option in group.options"
|
|
67
|
+
:key="option.value"
|
|
68
|
+
:option="option"
|
|
69
|
+
:is-selected="option.value === value"
|
|
70
|
+
/>
|
|
71
|
+
</select-group>
|
|
72
|
+
|
|
73
|
+
<!--
|
|
74
|
+
Ungrouped options render as direct children of the listbox:
|
|
75
|
+
an unlabelled SelectGroup would emit `role="group"` with a
|
|
76
|
+
dangling `aria-labelledby`.
|
|
77
|
+
-->
|
|
78
|
+
<template v-else>
|
|
79
|
+
<select-field-option
|
|
80
|
+
v-for="option in group.options"
|
|
81
|
+
:key="option.value"
|
|
82
|
+
:option="option"
|
|
83
|
+
:is-selected="option.value === value"
|
|
84
|
+
/>
|
|
85
|
+
</template>
|
|
86
|
+
</template>
|
|
87
|
+
</ds-select-list>
|
|
88
|
+
</select-viewport>
|
|
89
|
+
</select-content>
|
|
90
|
+
</select-portal>
|
|
91
|
+
</select-root>
|
|
92
|
+
</template>
|
|
93
|
+
<!-- begin: FormField slots -->
|
|
94
|
+
<template v-if="$slots.help" #help>
|
|
95
|
+
<slot name="help" />
|
|
96
|
+
</template>
|
|
97
|
+
<template v-if="$slots.labelAside" #labelAside>
|
|
98
|
+
<slot name="labelAside" />
|
|
99
|
+
</template>
|
|
100
|
+
<template v-if="$slots.message" #message="{ messageId }">
|
|
101
|
+
<slot name="message" :message-id="messageId" />
|
|
102
|
+
</template>
|
|
103
|
+
<template v-if="$slots.fieldStatus" #fieldStatus>
|
|
104
|
+
<slot name="fieldStatus" />
|
|
105
|
+
</template>
|
|
106
|
+
<!-- end: FormField slots -->
|
|
107
|
+
</form-field>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<style lang="scss" scoped>
|
|
111
|
+
@import '../../../../styles/settings/spacings';
|
|
112
|
+
@import '../../../../styles/settings/radiuses';
|
|
113
|
+
@import '../../../../styles/settings/borders';
|
|
114
|
+
@import '../../../../styles/settings/colors/tokens';
|
|
115
|
+
@import '../../../../styles/settings/typography/tokens';
|
|
116
|
+
@import '../../../../styles/settings/shadows';
|
|
117
|
+
@import '../../../../styles/settings/animations';
|
|
118
|
+
|
|
119
|
+
.ds-selectField {
|
|
120
|
+
$root: &;
|
|
121
|
+
|
|
122
|
+
// The element rules come before `&__trigger` so that the trigger's state overrides, which
|
|
123
|
+
// target the same elements at a higher specificity, stay in ascending order.
|
|
124
|
+
&__leftIcon {
|
|
125
|
+
color: $color-neutral-icon-weak;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
&__value {
|
|
129
|
+
@include formText-s-default-regular;
|
|
130
|
+
|
|
131
|
+
color: $color-neutral-text-heavy;
|
|
132
|
+
flex: 1;
|
|
133
|
+
// override the default min-width so long values can ellipsize
|
|
134
|
+
min-width: 0;
|
|
135
|
+
overflow: hidden;
|
|
136
|
+
text-align: left;
|
|
137
|
+
text-overflow: ellipsis;
|
|
138
|
+
white-space: nowrap;
|
|
139
|
+
|
|
140
|
+
&[data-placeholder] {
|
|
141
|
+
color: $color-neutral-text-weak;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
&__icon {
|
|
146
|
+
color: $color-neutral-icon;
|
|
147
|
+
transition: $default-cubic-bezier-transition;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
&__trigger {
|
|
151
|
+
align-items: center;
|
|
152
|
+
// FormField__field is `align-items: flex-start`, so the trigger must opt into full width.
|
|
153
|
+
align-self: stretch;
|
|
154
|
+
background: $color-default-background;
|
|
155
|
+
border: $border-xs solid $color-neutral-border-strong;
|
|
156
|
+
border-radius: $radius-s;
|
|
157
|
+
box-shadow: $shadow-inset-m;
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
display: flex;
|
|
160
|
+
font-family: inherit;
|
|
161
|
+
gap: $space-4;
|
|
162
|
+
min-height: 32px;
|
|
163
|
+
// Focus is conveyed by the border color below, so the UA ring is suppressed
|
|
164
|
+
outline: none;
|
|
165
|
+
padding: 0 $space-4;
|
|
166
|
+
text-align: left;
|
|
167
|
+
|
|
168
|
+
// The trigger is a button, so `[data-state='open']` replaces InputField's `:focus-within`.
|
|
169
|
+
&[data-state='open'] {
|
|
170
|
+
border-color: $color-primary-border;
|
|
171
|
+
|
|
172
|
+
#{$root}__icon {
|
|
173
|
+
transform: rotate(180deg);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
&:focus-visible {
|
|
178
|
+
border-color: $color-primary-border;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
&.-ds-disabled {
|
|
182
|
+
border-color: $color-neutral-border-strong-disabled;
|
|
183
|
+
box-shadow: none;
|
|
184
|
+
cursor: default;
|
|
185
|
+
|
|
186
|
+
#{$root}__leftIcon {
|
|
187
|
+
color: $color-neutral-icon-weak-disabled;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#{$root}__value {
|
|
191
|
+
color: $color-neutral-text-heavy-disabled;
|
|
192
|
+
|
|
193
|
+
&[data-placeholder] {
|
|
194
|
+
color: $color-neutral-text-weak-disabled;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#{$root}__icon {
|
|
199
|
+
color: $color-neutral-icon-disabled;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `:focus-visible` is excluded so that focusing an errored trigger shows the primary
|
|
204
|
+
// border, the same way InputField's error rule yields to `:focus-within`.
|
|
205
|
+
&.-ds-error:not([data-state='open']):not(:focus-visible) {
|
|
206
|
+
border-color: $color-danger-border;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
&:hover:not(.-ds-disabled):not([data-state='open']) {
|
|
210
|
+
border-color: $color-neutral-border-strong-hovered;
|
|
211
|
+
|
|
212
|
+
#{$root}__icon {
|
|
213
|
+
color: $color-neutral-icon-hovered;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
</style>
|
|
219
|
+
|
|
220
|
+
<!--
|
|
221
|
+
`ds-selectField__content` is styled unscoped on purpose. SelectContent renders through
|
|
222
|
+
Presence's slot rather than as its root, so Vue never forwards this component's scope id to
|
|
223
|
+
that element — a scoped rule would not match it. It is also portalled to `<body>`, so
|
|
224
|
+
`:deep()` has no ancestor to hang off either. Same approach as DatePicker.vue.
|
|
225
|
+
-->
|
|
226
|
+
<style lang="scss">
|
|
227
|
+
@import '../../../../styles/settings/z-indexes';
|
|
228
|
+
@import '../../../../styles/mixins/dropdown-surface';
|
|
229
|
+
|
|
230
|
+
.ds-selectField__content {
|
|
231
|
+
@include dropdownSurface;
|
|
232
|
+
@include dropdownSurfaceRadiusBottom;
|
|
233
|
+
|
|
234
|
+
// SelectViewport owns the scrolling; this is the flex column that caps the height.
|
|
235
|
+
display: flex;
|
|
236
|
+
flex-direction: column;
|
|
237
|
+
max-height: min(
|
|
238
|
+
var(--select-field-max-height, 320px),
|
|
239
|
+
var(--reka-select-content-available-height)
|
|
240
|
+
);
|
|
241
|
+
// Widens to fit the longest option but never narrower than the trigger, and never wider
|
|
242
|
+
// than the space available in the viewport.
|
|
243
|
+
max-width: var(--reka-select-content-available-width);
|
|
244
|
+
min-width: var(--reka-select-trigger-width);
|
|
245
|
+
// Portalled to <body>, so it needs to clear modal content. It sits on the modal layer
|
|
246
|
+
// rather than above it: the listbox is appended after the (also teleported) modal, so an
|
|
247
|
+
// equal z-index still paints it on top, and tooltips at 16777271 stay above both.
|
|
248
|
+
z-index: $z-index-modal;
|
|
249
|
+
}
|
|
250
|
+
</style>
|
|
251
|
+
|
|
252
|
+
<script lang="ts" setup>
|
|
253
|
+
import { computed, CSSProperties } from 'vue';
|
|
254
|
+
import {
|
|
255
|
+
SelectContent,
|
|
256
|
+
SelectGroup,
|
|
257
|
+
SelectIcon,
|
|
258
|
+
SelectLabel,
|
|
259
|
+
SelectPortal,
|
|
260
|
+
SelectRoot,
|
|
261
|
+
SelectSeparator,
|
|
262
|
+
SelectTrigger,
|
|
263
|
+
SelectValue,
|
|
264
|
+
SelectViewport,
|
|
265
|
+
} from 'reka-ui';
|
|
266
|
+
import FormField, { FORM_FIELD_STATES, FormFieldProps } from '../FormField';
|
|
267
|
+
import { extractFormFieldProps } from '../FormField/FormField.utils';
|
|
268
|
+
import DsIcon, { ICON_SIZES, ICONS } from '../../Icons/Icon';
|
|
269
|
+
import DsSelectList from '../../SelectList/SelectList.vue';
|
|
270
|
+
import DsSelectListItemDivider from '../../SelectList/SelectListItemDivider/SelectListItemDivider.vue';
|
|
271
|
+
import DsSelectListSectionTitle from '../../SelectList/SelectListSectionTitle/SelectListSectionTitle.vue';
|
|
272
|
+
import SelectFieldOption from './SelectFieldOption.vue';
|
|
273
|
+
import { SelectFieldProps, SelectFieldSlots, SelectFieldValue } from './SelectField.types';
|
|
274
|
+
import { assertOptionValues, normalizeOptions, toCssLength } from './SelectField.utils';
|
|
275
|
+
import { useSelectFieldWithinForm } from './useSelectFieldWithinForm';
|
|
276
|
+
|
|
277
|
+
const {
|
|
278
|
+
options,
|
|
279
|
+
placeholder,
|
|
280
|
+
leftIcon,
|
|
281
|
+
ariaLabel,
|
|
282
|
+
maxHeight,
|
|
283
|
+
name,
|
|
284
|
+
autocomplete,
|
|
285
|
+
isGroupLabelUppercase = true,
|
|
286
|
+
...rest
|
|
287
|
+
} = defineProps<SelectFieldProps>();
|
|
288
|
+
|
|
289
|
+
const slots = defineSlots<SelectFieldSlots>();
|
|
290
|
+
|
|
291
|
+
const emit = defineEmits<{
|
|
292
|
+
'open-change': [isOpen: boolean];
|
|
293
|
+
}>();
|
|
294
|
+
|
|
295
|
+
const modelValue = defineModel<SelectFieldValue>();
|
|
296
|
+
|
|
297
|
+
const { value, errors, onClose } = useSelectFieldWithinForm(() => name, modelValue);
|
|
298
|
+
|
|
299
|
+
const { formFieldProps, isDisabled, hasMessage } = useFormFieldState();
|
|
300
|
+
const { normalizedOptions } = useOptions();
|
|
301
|
+
|
|
302
|
+
const contentStyle = computed<CSSProperties | undefined>(() => {
|
|
303
|
+
const resolvedMaxHeight = toCssLength(maxHeight);
|
|
304
|
+
|
|
305
|
+
return resolvedMaxHeight
|
|
306
|
+
? ({ '--select-field-max-height': resolvedMaxHeight } as CSSProperties)
|
|
307
|
+
: undefined;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
function onOpenChange(isOpen: boolean) {
|
|
311
|
+
// Closing the listbox is a select's equivalent of blur.
|
|
312
|
+
if (!isOpen) {
|
|
313
|
+
onClose(new Event('blur'));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
emit('open-change', isOpen);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function useFormFieldState() {
|
|
320
|
+
const formFieldProps = computed<FormFieldProps>(() =>
|
|
321
|
+
// this is needed to avoid passing modelValue to FormField as prop
|
|
322
|
+
extractFormFieldProps(rest, errors.value),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const isDisabled = computed(() => formFieldProps.value.state === FORM_FIELD_STATES.DISABLED);
|
|
326
|
+
|
|
327
|
+
// Avoids pointing `aria-describedby` at a message element that is never rendered.
|
|
328
|
+
const hasMessage = computed(() => !!(formFieldProps.value.messageText || slots.message));
|
|
329
|
+
|
|
330
|
+
return { formFieldProps, isDisabled, hasMessage };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function useOptions() {
|
|
334
|
+
const normalizedOptions = computed(() => normalizeOptions(options));
|
|
335
|
+
|
|
336
|
+
// Dev-time guard, called synchronously so invalid options fail at mount instead of
|
|
337
|
+
// surfacing as an unhandled rejection while the offscreen content fragment renders.
|
|
338
|
+
assertOptionValues(normalizedOptions.value);
|
|
339
|
+
|
|
340
|
+
return { normalizedOptions };
|
|
341
|
+
}
|
|
342
|
+
</script>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<select-item
|
|
3
|
+
as-child
|
|
4
|
+
:value="option.value"
|
|
5
|
+
:disabled="option.isDisabled"
|
|
6
|
+
:text-value="option.label"
|
|
7
|
+
:aria-label="option.eyebrowText ? getOptionAccessibleName(option) : undefined"
|
|
8
|
+
class="ds-selectFieldOption"
|
|
9
|
+
>
|
|
10
|
+
<ds-select-list-item
|
|
11
|
+
:label="option.label"
|
|
12
|
+
:icon-left="option.iconLeft"
|
|
13
|
+
:eyebrow-text="option.eyebrowText"
|
|
14
|
+
:is-selected="isSelected"
|
|
15
|
+
:state="
|
|
16
|
+
option.isDisabled
|
|
17
|
+
? SELECT_LIST_ITEM_STATES.DISABLED
|
|
18
|
+
: SELECT_LIST_ITEM_STATES.DEFAULT
|
|
19
|
+
"
|
|
20
|
+
>
|
|
21
|
+
<!--
|
|
22
|
+
reka-ui names each option via `aria-labelledby` pointing at the id rendered by
|
|
23
|
+
SelectItemText, so the visible label must live inside it.
|
|
24
|
+
-->
|
|
25
|
+
<template #text>
|
|
26
|
+
<select-item-text>{{ option.label }}</select-item-text>
|
|
27
|
+
</template>
|
|
28
|
+
</ds-select-list-item>
|
|
29
|
+
</select-item>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<style lang="scss">
|
|
33
|
+
.ds-selectFieldOption {
|
|
34
|
+
// The highlighted state is conveyed by SelectListItem's own styling, so the UA focus ring
|
|
35
|
+
// is suppressed
|
|
36
|
+
outline: none;
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
39
|
+
|
|
40
|
+
<script lang="ts" setup>
|
|
41
|
+
import { SelectItem, SelectItemText } from 'reka-ui';
|
|
42
|
+
import DsSelectListItem from '../../SelectList/SelectListItem/SelectListItem.vue';
|
|
43
|
+
import { SELECT_LIST_ITEM_STATES } from '../../SelectList/SelectListItem/SelectListItem.consts';
|
|
44
|
+
import { SelectFieldOption as SelectFieldOptionType } from './SelectField.types';
|
|
45
|
+
import { getOptionAccessibleName } from './SelectField.utils';
|
|
46
|
+
|
|
47
|
+
const { option, isSelected = false } = defineProps<{
|
|
48
|
+
option: SelectFieldOptionType;
|
|
49
|
+
isSelected?: boolean;
|
|
50
|
+
}>();
|
|
51
|
+
</script>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { MaybeRefOrGetter, Ref } from 'vue';
|
|
2
|
+
import { useFormFieldWithinForm } from '../../../composables/useFormFieldWithinForm';
|
|
3
|
+
import { SelectFieldValue } from './SelectField.types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Select counterpart of `useInputFieldWithinForm`. A select has no `input` event, so closing
|
|
7
|
+
* the listbox stands in for blur — that is when the field is marked touched.
|
|
8
|
+
*
|
|
9
|
+
* Revalidation on selection needs no handler here: vee-validate's `useField` defaults to
|
|
10
|
+
* `validateOnValueUpdate: true`, so changing the value already re-runs validation.
|
|
11
|
+
*/
|
|
12
|
+
export function useSelectFieldWithinForm(
|
|
13
|
+
name: MaybeRefOrGetter<string | undefined>,
|
|
14
|
+
modelValue: Ref<SelectFieldValue | undefined>,
|
|
15
|
+
) {
|
|
16
|
+
const { value, errors, field } = useFormFieldWithinForm(name, modelValue);
|
|
17
|
+
|
|
18
|
+
const onClose = (event: Event) => {
|
|
19
|
+
field?.handleBlur(event, true);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
value,
|
|
24
|
+
errors,
|
|
25
|
+
onClose,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { h } from 'vue';
|
|
4
|
+
import { ComponentProps } from 'vue-component-type-helpers';
|
|
5
|
+
import SelectListItem from './SelectListItem.vue';
|
|
6
|
+
import { SELECT_LIST_ITEM_SIZES, SELECT_LIST_ITEM_STATES } from './SelectListItem.consts';
|
|
7
|
+
import Icon, { ICONS } from '../../Icons/Icon';
|
|
8
|
+
|
|
9
|
+
const setup = (props: ComponentProps<typeof SelectListItem>, slots = {}) =>
|
|
10
|
+
mount(SelectListItem, { props, slots });
|
|
11
|
+
|
|
12
|
+
describe('SelectListItem', () => {
|
|
13
|
+
it('should render the label', () => {
|
|
14
|
+
const wrapper = setup({ label: 'Basic listItem' });
|
|
15
|
+
|
|
16
|
+
expect(wrapper.find('.ds-selectListItem').exists()).toBe(true);
|
|
17
|
+
expect(wrapper.find('.ds-selectListItem__text').text()).toBe('Basic listItem');
|
|
18
|
+
expect(wrapper.find('.ds-selectListItem').attributes('title')).toBe('Basic listItem');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should render the eyebrow text when provided', () => {
|
|
22
|
+
const wrapper = setup({ label: 'Label', eyebrowText: 'Eyebrow' });
|
|
23
|
+
|
|
24
|
+
expect(wrapper.find('.ds-selectListItem__eyebrowText').text()).toBe('Eyebrow');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it.each(Object.values(SELECT_LIST_ITEM_SIZES))('should render in size: %s', (size) => {
|
|
28
|
+
const wrapper = setup({ label: 'Label', size });
|
|
29
|
+
|
|
30
|
+
expect(wrapper.find('.ds-selectListItem').classes()).toContain(`-ds-${size}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it.each([
|
|
34
|
+
{ state: SELECT_LIST_ITEM_STATES.DISABLED, expectedClass: '-ds-disabled' },
|
|
35
|
+
{ state: SELECT_LIST_ITEM_STATES.LOADING, expectedClass: '-ds-loading' },
|
|
36
|
+
])('should apply $expectedClass for state $state', ({ state, expectedClass }) => {
|
|
37
|
+
const wrapper = setup({ label: 'Label', state });
|
|
38
|
+
|
|
39
|
+
expect(wrapper.find('.ds-selectListItem').classes()).toContain(expectedClass);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should render the check icon when selected', () => {
|
|
43
|
+
const wrapper = setup({ label: 'Label', isSelected: true });
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
wrapper.findComponent<typeof Icon>('.ds-selectListItem__iconRight').props().icon,
|
|
47
|
+
).toEqual(ICONS.FA_CHECK_SOLID);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should not render the check icon when not selected', () => {
|
|
51
|
+
const wrapper = setup({ label: 'Label' });
|
|
52
|
+
|
|
53
|
+
expect(wrapper.find('.ds-selectListItem__iconRight').exists()).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('text slot', () => {
|
|
57
|
+
it('should fall back to the label when the slot is not provided', () => {
|
|
58
|
+
const wrapper = setup({ label: 'Fallback label' });
|
|
59
|
+
|
|
60
|
+
expect(wrapper.find('.ds-selectListItem__text').text()).toBe('Fallback label');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should override the rendered label when the slot is provided', () => {
|
|
64
|
+
const wrapper = setup(
|
|
65
|
+
{ label: 'Prop label' },
|
|
66
|
+
{ text: () => h('span', { id: 'custom-text' }, 'Slot label') },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const text = wrapper.find('.ds-selectListItem__text');
|
|
70
|
+
|
|
71
|
+
expect(text.find('#custom-text').exists()).toBe(true);
|
|
72
|
+
expect(text.text()).toBe('Slot label');
|
|
73
|
+
// `label` is still used for the title attribute and typeahead fallback.
|
|
74
|
+
expect(wrapper.find('.ds-selectListItem').attributes('title')).toBe('Prop label');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
:class="{ '-ds-uppercase': isEyebrowTextUppercase }"
|
|
28
28
|
>{{ eyebrowText }}</span
|
|
29
29
|
>
|
|
30
|
-
<span class="ds-selectListItem__text"
|
|
30
|
+
<span class="ds-selectListItem__text"
|
|
31
|
+
><slot name="text">{{ label }}</slot></span
|
|
32
|
+
>
|
|
31
33
|
</span>
|
|
32
34
|
|
|
33
35
|
<slot name="metadata" />
|
|
@@ -197,6 +199,7 @@ const {
|
|
|
197
199
|
const slots = defineSlots<{
|
|
198
200
|
accessory?: () => any;
|
|
199
201
|
metadata?: () => any;
|
|
202
|
+
text?: () => any;
|
|
200
203
|
}>();
|
|
201
204
|
|
|
202
205
|
const isLoading = computed(() => state === SELECT_LIST_ITEM_STATES.LOADING);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { inject, MaybeRefOrGetter, ref, Ref, toValue } from 'vue';
|
|
2
2
|
import { FormContextKey, useField } from 'vee-validate';
|
|
3
3
|
|
|
4
|
-
export function useFormFieldWithinForm<T
|
|
4
|
+
export function useFormFieldWithinForm<T>(
|
|
5
5
|
name: MaybeRefOrGetter<string | undefined>,
|
|
6
|
-
modelValue: Ref<T
|
|
6
|
+
modelValue: Ref<T>,
|
|
7
7
|
) {
|
|
8
8
|
const nameValue = toValue(name);
|
|
9
9
|
const form = inject(FormContextKey, null);
|
|
@@ -14,9 +14,9 @@ export function useFormFieldWithinForm<T = string>(
|
|
|
14
14
|
);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const field = nameValue ? useField<
|
|
17
|
+
const field = nameValue ? useField<T>(nameValue, undefined) : null;
|
|
18
18
|
|
|
19
|
-
const value = field ? field.value : modelValue;
|
|
19
|
+
const value: Ref<T> = field ? field.value : modelValue;
|
|
20
20
|
|
|
21
21
|
return {
|
|
22
22
|
field,
|
package/lib/js/index.ts
CHANGED
|
@@ -53,6 +53,8 @@ export { default as NumberInCircle } from './components/NumberInCircle';
|
|
|
53
53
|
export { default as DsNumberInCircle } from './components/NumberInCircle';
|
|
54
54
|
export * from './components/NumberInCircle/NumberInCircle.consts';
|
|
55
55
|
export { default as DsPasswordField } from './components/Form/PasswordField';
|
|
56
|
+
export { default as DsSelectField } from './components/Form/SelectField';
|
|
57
|
+
export * from './components/Form/SelectField';
|
|
56
58
|
export { default as TabItem } from './components/TabItem';
|
|
57
59
|
export { default as DsTabItem } from './components/TabItem';
|
|
58
60
|
export * from './components/TabItem/TabItem.consts';
|
|
@@ -23,6 +23,22 @@
|
|
|
23
23
|
box-shadow: $shadow-xl;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
&.-ds-shadowTopS {
|
|
27
|
+
box-shadow: $shadow-top-s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&.-ds-shadowTopM {
|
|
31
|
+
box-shadow: $shadow-top-m;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
&.-ds-shadowTopL {
|
|
35
|
+
box-shadow: $shadow-top-l;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&.-ds-shadowTopXl {
|
|
39
|
+
box-shadow: $shadow-top-xl;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
&.-ds-shadowInsetS {
|
|
27
43
|
box-shadow: $shadow-inset-s;
|
|
28
44
|
}
|
|
@@ -12,6 +12,10 @@ const shadows = [
|
|
|
12
12
|
{ name: 'shadow-m', modifier: '-ds-shadowM' },
|
|
13
13
|
{ name: 'shadow-l', modifier: '-ds-shadowL' },
|
|
14
14
|
{ name: 'shadow-xl', modifier: '-ds-shadowXl' },
|
|
15
|
+
{ name: 'shadow-top-s', modifier: '-ds-shadowTopS' },
|
|
16
|
+
{ name: 'shadow-top-m', modifier: '-ds-shadowTopM' },
|
|
17
|
+
{ name: 'shadow-top-l', modifier: '-ds-shadowTopL' },
|
|
18
|
+
{ name: 'shadow-top-xl', modifier: '-ds-shadowTopXl' },
|
|
15
19
|
{ name: 'shadow-inset-s', modifier: '-ds-shadowInsetS' },
|
|
16
20
|
{ name: 'shadow-inset-m', modifier: '-ds-shadowInsetM' },
|
|
17
21
|
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
@import '../settings/colors/tokens';
|
|
2
|
+
@import '../settings/radiuses';
|
|
3
|
+
@import '../settings/shadows';
|
|
4
|
+
|
|
5
|
+
// The visual surface shared by floating panels — DsDropdown and SelectField's listbox.
|
|
6
|
+
// Deliberately covers appearance only: sizing, scrolling and positioning stay with the
|
|
7
|
+
// consumer, because those legitimately differ between popper engines and use cases.
|
|
8
|
+
@mixin dropdownSurface {
|
|
9
|
+
background-color: $color-default-background;
|
|
10
|
+
box-shadow: $shadow-m;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Applied separately from `dropdownSurface`, since a panel anchored below its trigger keeps
|
|
15
|
+
// its top corners square (and vice versa).
|
|
16
|
+
@mixin dropdownSurfaceRadiusTop {
|
|
17
|
+
border-top-left-radius: $radius-s;
|
|
18
|
+
border-top-right-radius: $radius-s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@mixin dropdownSurfaceRadiusBottom {
|
|
22
|
+
border-bottom-left-radius: $radius-s;
|
|
23
|
+
border-bottom-right-radius: $radius-s;
|
|
24
|
+
}
|
|
@@ -23,6 +23,24 @@ $shadow-xl:
|
|
|
23
23
|
0 16px 32px $color-default-shadow-strong,
|
|
24
24
|
0 0 4px $color-default-shadow;
|
|
25
25
|
|
|
26
|
+
$shadow-top-s:
|
|
27
|
+
0 -1px 2px $color-default-shadow-weak,
|
|
28
|
+
0 -8px 16px $color-default-shadow-weak,
|
|
29
|
+
0 -2px 4px $color-default-shadow-strong,
|
|
30
|
+
0 0 4px $color-default-shadow;
|
|
31
|
+
$shadow-top-m:
|
|
32
|
+
0 -2px 4px $color-default-shadow-weak,
|
|
33
|
+
0 -6px 12px $color-default-shadow-strong,
|
|
34
|
+
0 0 4px $color-default-shadow;
|
|
35
|
+
$shadow-top-l:
|
|
36
|
+
0 -4px 8px $color-default-shadow-weak,
|
|
37
|
+
0 -10px 20px $color-default-shadow-strong,
|
|
38
|
+
0 0 4px $color-default-shadow;
|
|
39
|
+
$shadow-top-xl:
|
|
40
|
+
0 -8px 16px $color-default-shadow-weak,
|
|
41
|
+
0 -16px 32px $color-default-shadow-strong,
|
|
42
|
+
0 0 4px $color-default-shadow;
|
|
43
|
+
|
|
26
44
|
$shadow-inset-m: inset 0 1px 4px $color-default-shadow-heavy;
|
|
27
45
|
$shadow-inset-s: inset 0 1px 3px $color-default-shadow-heavy;
|
|
28
46
|
|