@ditojs/ui 3.2.6 → 3.3.1

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.
@@ -0,0 +1,226 @@
1
+ <template lang="pug">
2
+ DitoTrigger.dito-menu-button(
3
+ v-model:show="isOpen"
4
+ trigger="click"
5
+ :placement="placement"
6
+ :disabled="disabled"
7
+ :matchTargetWidth="false"
8
+ :cover="cover"
9
+ :transition="null"
10
+ :class=`{
11
+ 'dito-menu-button--open': isOpen,
12
+ 'dito-menu-button--offset': offset
13
+ }`
14
+ )
15
+ template(#trigger)
16
+ DitoButton(
17
+ ref="button"
18
+ aria-haspopup="menu"
19
+ :aria-expanded="isOpen ? 'true' : 'false'"
20
+ :disabled="disabled"
21
+ v-bind="$attrs"
22
+ @keydown="onButtonKeyDown"
23
+ @keyup="onKeyUp"
24
+ )
25
+ slot
26
+ template(#popup)
27
+ ul.dito-menu-button__menu(
28
+ role="menu"
29
+ @keydown="onMenuKeyDown"
30
+ @keyup="onKeyUp"
31
+ )
32
+ li(
33
+ v-for="item in items"
34
+ :key="item.value"
35
+ role="none"
36
+ )
37
+ button.dito-menu-button__item(
38
+ :ref="element => setItemElement(item, element)"
39
+ type="button"
40
+ role="menuitem"
41
+ tabindex="-1"
42
+ :aria-disabled="item.disabled ? 'true' : null"
43
+ :class="{ 'dito-menu-button__item--disabled': item.disabled }"
44
+ @click="selectItem(item)"
45
+ ) {{ item.label }}
46
+ </template>
47
+
48
+ <script>
49
+ import DitoTrigger from './DitoTrigger.vue'
50
+ import DitoButton from './DitoButton.vue'
51
+
52
+ // A button that opens a menu of `items`, each `{ value, label, disabled }`,
53
+ // and emits `select` with the item that is chosen, by mouse or keyboard.
54
+ // Attributes, e.g. `class` or the `verb` and `text` of `DitoButton`, go to the
55
+ // button, its content to the default slot.
56
+ export default {
57
+ components: { DitoTrigger, DitoButton },
58
+ inheritAttrs: false,
59
+ emits: ['select'],
60
+
61
+ props: {
62
+ items: { type: Array, required: true },
63
+ placement: { type: String, default: 'bottom' },
64
+ // Whether the menu opens over the button, like native menus do.
65
+ cover: { type: Boolean, default: true },
66
+ // Whether the menu opens one item padding below the top of the button,
67
+ // e.g. to line up with text inside a taller button.
68
+ offset: { type: Boolean, default: false },
69
+ disabled: { type: Boolean, default: false }
70
+ },
71
+
72
+ data() {
73
+ return {
74
+ isOpen: false
75
+ }
76
+ },
77
+
78
+ created() {
79
+ // The elements of the rendered items, by item.
80
+ this.itemElements = new Map()
81
+ },
82
+
83
+ methods: {
84
+ setItemElement(item, element) {
85
+ if (element) {
86
+ this.itemElements.set(item, element)
87
+ } else {
88
+ this.itemElements.delete(item)
89
+ }
90
+ },
91
+
92
+ getFocusedItemIndex() {
93
+ return this.items.findIndex(
94
+ item => this.itemElements.get(item) === document.activeElement
95
+ )
96
+ },
97
+
98
+ focusItemAt(index) {
99
+ this.itemElements.get(this.items[index])?.focus()
100
+ },
101
+
102
+ async open(focusIndex) {
103
+ this.isOpen = true
104
+ // Wait for the menu to be rendered, so its items can be focused.
105
+ await this.$nextTick()
106
+ this.focusItemAt(focusIndex)
107
+ },
108
+
109
+ close() {
110
+ this.isOpen = false
111
+ this.focusButton()
112
+ },
113
+
114
+ focusButton() {
115
+ this.$refs.button.$el.focus()
116
+ },
117
+
118
+ selectItem(item) {
119
+ if (!item.disabled) {
120
+ this.close()
121
+ this.$emit('select', item)
122
+ }
123
+ },
124
+
125
+ onButtonKeyDown(event) {
126
+ const { key } = event
127
+ if (key === 'Enter' || key === ' ' || key === 'ArrowDown') {
128
+ event.preventDefault()
129
+ this.open(0)
130
+ } else if (key === 'ArrowUp') {
131
+ event.preventDefault()
132
+ this.open(this.items.length - 1)
133
+ }
134
+ },
135
+
136
+ onMenuKeyDown(event) {
137
+ const { key } = event
138
+ const count = this.items.length
139
+ const index = this.getFocusedItemIndex()
140
+ if (key === 'Escape') {
141
+ // `DitoTrigger` closes the menu, return the focus to the button.
142
+ this.focusButton()
143
+ } else if (key === 'Enter' || key === ' ') {
144
+ event.preventDefault()
145
+ const item = this.items[index]
146
+ if (item) {
147
+ this.selectItem(item)
148
+ }
149
+ } else if (key === 'Tab') {
150
+ // Let the focus move on as usual, but close the menu.
151
+ this.isOpen = false
152
+ } else {
153
+ const targetIndex = {
154
+ ArrowDown: (index + 1) % count,
155
+ ArrowUp: (index - 1 + count) % count,
156
+ Home: 0,
157
+ End: count - 1
158
+ }[key]
159
+ if (targetIndex !== undefined) {
160
+ event.preventDefault()
161
+ this.focusItemAt(targetIndex)
162
+ }
163
+ }
164
+ },
165
+
166
+ onKeyUp(event) {
167
+ // Firefox clicks buttons on the keyup of Space even if its keydown was
168
+ // prevented, which would toggle the menu again or select an item, see
169
+ // `onButtonKeyDown()` and `onMenuKeyDown()`.
170
+ if (event.key === ' ') {
171
+ event.preventDefault()
172
+ }
173
+ }
174
+ }
175
+ }
176
+ </script>
177
+
178
+ <style lang="scss">
179
+ @import '../styles/_imports';
180
+
181
+ $menu-button-radius: 0.5em;
182
+ $menu-button-padding-ver: 0.5em;
183
+ $menu-button-padding: $menu-button-padding-ver 1em;
184
+
185
+ .dito-menu-button {
186
+ display: inline-block;
187
+
188
+ &__menu {
189
+ margin: 0;
190
+ padding: 0;
191
+ list-style: none;
192
+ border-radius: $menu-button-radius;
193
+ box-shadow: $shadow-window;
194
+ overflow: hidden;
195
+ }
196
+
197
+ &--offset &__menu {
198
+ margin-top: $menu-button-padding-ver;
199
+ }
200
+
201
+ &__item {
202
+ display: block;
203
+ width: 100%;
204
+ padding: $menu-button-padding;
205
+ border: 0;
206
+ font: inherit;
207
+ line-height: 1;
208
+ text-align: left;
209
+ white-space: nowrap;
210
+ color: inherit;
211
+ background: $button-color;
212
+ @include user-select(none);
213
+
214
+ &--disabled {
215
+ color: $color-disabled;
216
+ cursor: default;
217
+ }
218
+
219
+ &:not(&--disabled):is(:hover, :focus-visible) {
220
+ background: $color-active;
221
+ color: $color-white;
222
+ outline: none;
223
+ }
224
+ }
225
+ }
226
+ </style>
@@ -10,8 +10,9 @@ nav.dito-pagination(aria-label="Pagination")
10
10
  .dito-buttons.dito-buttons--round(
11
11
  v-if="numPages > 1"
12
12
  )
13
- button.dito-button(
13
+ DitoButton(
14
14
  v-for="button in buttons"
15
+ :key="button.type || button.index"
15
16
  :class="getButtonClasses(button)"
16
17
  :disabled="button.disabled"
17
18
  :aria-label="getButtonLabel(button)"
@@ -24,7 +25,10 @@ nav.dito-pagination(aria-label="Pagination")
24
25
  </template>
25
26
 
26
27
  <script>
28
+ import DitoButton from './DitoButton.vue'
29
+
27
30
  export default {
31
+ components: { DitoButton },
28
32
  emits: ['update:page'],
29
33
 
30
34
  props: {
@@ -120,8 +124,10 @@ export default {
120
124
  },
121
125
 
122
126
  pageSize() {
123
- if (this.currentPage > this.numPages) {
124
- this.currentPage = this.numPages
127
+ // Without items there are no pages, but page 1 remains valid.
128
+ const lastPage = Math.max(1, this.numPages)
129
+ if (this.currentPage > lastPage) {
130
+ this.currentPage = lastPage
125
131
  }
126
132
  },
127
133
 
@@ -0,0 +1,285 @@
1
+ <template lang="pug">
2
+ DitoTrigger.dito-picker-input(
3
+ ref="trigger"
4
+ trigger="focus"
5
+ v-bind="{ show, transition, placement, disabled, target }"
6
+ @update:show="onUpdateShow"
7
+ )
8
+ template(#trigger)
9
+ slot(
10
+ v-if="$slots.trigger"
11
+ name="trigger"
12
+ )
13
+ DitoInput(
14
+ v-else
15
+ ref="input"
16
+ v-model="currentText"
17
+ type="text"
18
+ :class="inputClass"
19
+ v-bind="{ placeholder, disabled, focused: isFocused, ...inputAttributes }"
20
+ @focus="onInputFocus"
21
+ @blur="isInputFocused = false"
22
+ @click="onInputClick"
23
+ @keydown="onInputKeyDown"
24
+ @keyup="updateCaretPart"
25
+ )
26
+ template(#prefix)
27
+ slot(name="prefix")
28
+ template(#suffix)
29
+ button.dito-picker-input__toggle(
30
+ type="button"
31
+ :aria-label="toggleLabel"
32
+ aria-haspopup="dialog"
33
+ :aria-expanded="isPopupShown ? 'true' : 'false'"
34
+ :disabled="disabled"
35
+ @mousedown.stop.prevent
36
+ @click="onToggleClick"
37
+ )
38
+ DitoIcon(
39
+ :name="icon"
40
+ :disabled="disabled"
41
+ )
42
+ slot(name="suffix")
43
+ template(#popup)
44
+ slot(
45
+ name="popup"
46
+ :value="currentValue"
47
+ :setValue="setValue"
48
+ :close="close"
49
+ )
50
+ </template>
51
+
52
+ <script>
53
+ import { format } from '@ditojs/utils'
54
+ import DitoTrigger from './DitoTrigger.vue'
55
+ import DitoInput from './DitoInput.vue'
56
+ import DitoIcon from './DitoIcon.vue'
57
+ import { parseDate, getDatePartAtPosition } from '../utils/date.js'
58
+ import { getSelection, setSelection } from '../utils/selection.js'
59
+ import { getKeyNavigation } from '../utils/event.js'
60
+ import { isTimePartName, editTimeText } from '../utils/time.js'
61
+
62
+ // The input of the date and time pickers, with the icon button that toggles
63
+ // their popup. It owns the picked value, the text that represents it and the
64
+ // focus state of input and popup together. The popup's panel is rendered
65
+ // through the `popup` slot, and navigated with the keyboard through
66
+ // `handlePanelKey()`.
67
+ export default {
68
+ components: { DitoTrigger, DitoInput, DitoIcon },
69
+ emits: [
70
+ 'update:modelValue',
71
+ 'update:show',
72
+ 'change',
73
+ 'focus',
74
+ 'blur',
75
+ 'caretPartChange'
76
+ ],
77
+
78
+ props: {
79
+ modelValue: { type: Date, default: null },
80
+ formatOptions: { type: Object, required: true },
81
+ icon: { type: String, required: true },
82
+ toggleLabel: { type: String, required: true },
83
+ placeholder: { type: String, default: null },
84
+ show: { type: Boolean, default: false },
85
+ disabled: { type: Boolean, default: false },
86
+ transition: { type: String, default: 'slide' },
87
+ placement: { type: String, default: 'bottom-left' },
88
+ target: { type: [String, HTMLElement], default: 'trigger' },
89
+ inputClass: { type: String, default: null },
90
+ // The attributes and listeners of the native input.
91
+ inputAttributes: { type: Object, default: () => ({}) },
92
+ // Values that can't be picked, also not by typing them.
93
+ isValueDisabled: { type: Function, default: () => false },
94
+ // Called with the keydown event of the input and the date part at the
95
+ // caret, returning whether the panel handled it.
96
+ handlePanelKey: { type: Function, default: () => false }
97
+ },
98
+
99
+ data() {
100
+ return {
101
+ currentValue: this.modelValue,
102
+ isPopupShown: this.show,
103
+ isInputFocused: false,
104
+ caretPartName: null
105
+ }
106
+ },
107
+
108
+ computed: {
109
+ isFocused() {
110
+ return this.isInputFocused || this.isPopupShown
111
+ },
112
+
113
+ currentText: {
114
+ get() {
115
+ return format(this.currentValue, this.formatOptions) || ''
116
+ },
117
+
118
+ set(text) {
119
+ const date = parseDate(text, this.formatOptions)
120
+ if (date) {
121
+ // Keep the caret in place while the text is replaced with the one
122
+ // of the new value.
123
+ const input = this.getInputElement()
124
+ const selection = getSelection(input)
125
+ if (this.setValue(date) && selection) {
126
+ this.$nextTick(() => setSelection(input, selection))
127
+ }
128
+ }
129
+ }
130
+ }
131
+ },
132
+
133
+ watch: {
134
+ modelValue(to, from) {
135
+ if (+to !== +from) {
136
+ this.currentValue = to
137
+ }
138
+ },
139
+
140
+ isFocused(to, from) {
141
+ if (to ^ from) {
142
+ this.$emit(to ? 'focus' : 'blur')
143
+ }
144
+ }
145
+ },
146
+
147
+ methods: {
148
+ getInputElement() {
149
+ return this.$refs.input?.input ?? null
150
+ },
151
+
152
+ // All changes of the value go through here, so disabled values are never
153
+ // set. Returns whether the value was set.
154
+ setValue(value) {
155
+ if (value && this.isValueDisabled(value)) {
156
+ return false
157
+ }
158
+ if (+value !== +this.currentValue) {
159
+ this.currentValue = value
160
+ if (+value !== +this.modelValue) {
161
+ this.$emit('update:modelValue', value)
162
+ this.$emit('change', value)
163
+ }
164
+ }
165
+ return true
166
+ },
167
+
168
+ open() {
169
+ this.$refs.trigger.open()
170
+ },
171
+
172
+ close() {
173
+ this.$refs.trigger.close()
174
+ },
175
+
176
+ focus() {
177
+ this.$refs.input?.focus()
178
+ },
179
+
180
+ blur() {
181
+ this.$refs.input?.blur()
182
+ },
183
+
184
+ getCaretPart() {
185
+ const position = getSelection(this.getInputElement())?.start
186
+ return position != null
187
+ ? getDatePartAtPosition(this.currentText, position, this.formatOptions)
188
+ : null
189
+ },
190
+
191
+ updateCaretPart() {
192
+ const name = this.getCaretPart()?.name ?? null
193
+ if (name !== this.caretPartName) {
194
+ this.caretPartName = name
195
+ this.$emit('caretPartChange', name)
196
+ }
197
+ },
198
+
199
+ onUpdateShow(show) {
200
+ // `DitoTrigger` owns the popup's state, this only mirrors it.
201
+ this.isPopupShown = show
202
+ this.$emit('update:show', show)
203
+ },
204
+
205
+ onInputFocus() {
206
+ this.isInputFocused = true
207
+ this.updateCaretPart()
208
+ },
209
+
210
+ onInputClick() {
211
+ this.updateCaretPart()
212
+ // Reopen the popup when clicking into the focused input after it was
213
+ // closed, e.g. with Escape.
214
+ this.open()
215
+ },
216
+
217
+ onToggleClick() {
218
+ if (this.isPopupShown) {
219
+ this.close()
220
+ } else {
221
+ this.open()
222
+ this.focus()
223
+ }
224
+ },
225
+
226
+ onInputKeyDown(event) {
227
+ const { ver: step, enter } = getKeyNavigation(event)
228
+ const part = this.getCaretPart()
229
+ if (step || enter) {
230
+ event.preventDefault()
231
+ if (!this.isPopupShown) {
232
+ if (step) {
233
+ this.open()
234
+ }
235
+ } else if (this.handlePanelKey(event, part) && part) {
236
+ // Select the changed part again, once the text was updated.
237
+ const input = this.getInputElement()
238
+ this.$nextTick(() => {
239
+ const changedPart = getDatePartAtPosition(
240
+ this.currentText,
241
+ part.start,
242
+ this.formatOptions
243
+ )
244
+ if (changedPart) {
245
+ setSelection(input, changedPart)
246
+ }
247
+ })
248
+ }
249
+ } else if (isTimePartName(part?.name)) {
250
+ editTimeText(event, this.getInputElement(), this.currentText.length)
251
+ }
252
+ }
253
+ }
254
+ }
255
+ </script>
256
+
257
+ <style lang="scss">
258
+ @import '../styles/_imports';
259
+
260
+ .dito-picker-input {
261
+ .dito-input {
262
+ font-variant-numeric: tabular-nums;
263
+ width: 100%;
264
+ }
265
+
266
+ &__toggle {
267
+ display: flex;
268
+ padding: 0;
269
+ border: 0;
270
+ border-radius: $border-radius;
271
+ font: inherit;
272
+ color: inherit;
273
+ background: none;
274
+
275
+ &:disabled {
276
+ cursor: default;
277
+ }
278
+
279
+ &:focus-visible {
280
+ outline: none;
281
+ box-shadow: $shadow-focus;
282
+ }
283
+ }
284
+ }
285
+ </style>
@@ -62,8 +62,8 @@ export default {
62
62
 
63
63
  styles() {
64
64
  const { labels: { checked, unchecked } = {} } = this
65
- // Calculate `--switch-width` in `rem`, based on label length.
66
- const length = Math.max(0, checked?.length, unchecked?.length)
65
+ // Calculate `--switch-width` in `rem`, based on the given labels' length.
66
+ const length = Math.max(checked?.length ?? 0, unchecked?.length ?? 0)
67
67
  return {
68
68
  '--switch-width': length ? `${length * 1.5}rem` : null
69
69
  }
@@ -191,10 +191,8 @@ export default {
191
191
  @extend %button-disabled;
192
192
  }
193
193
 
194
- &:focus-within:not(:hover) {
195
- #{$self}__button {
196
- box-shadow: $shadow-focus;
197
- }
194
+ input:focus-visible + #{$self}__button {
195
+ box-shadow: $shadow-focus;
198
196
  }
199
197
  }
200
198
  </style>