@ditojs/ui 3.2.0 → 3.3.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.
@@ -1,6 +1,8 @@
1
1
  <!-- Derived from ATUI, and further extended: https://aliqin.github.io/atui/ -->
2
2
  <template lang="pug">
3
- .dito-trigger-container
3
+ .dito-trigger-container(
4
+ @keydown="onKeyDown"
5
+ )
4
6
  .dito-trigger(
5
7
  v-if="alwaysShow"
6
8
  ref="trigger"
@@ -28,7 +30,10 @@
28
30
  :class="triggerClass"
29
31
  )
30
32
  slot(name="trigger")
31
- Transition(:name="`dito-${transition}`")
33
+ Transition(
34
+ :name="transition ? `dito-${transition}` : undefined"
35
+ :css="!!transition"
36
+ )
32
37
  .dito-popup(
33
38
  v-if="trigger === 'hover'"
34
39
  v-show="showPopup"
@@ -60,11 +65,20 @@ import { hyphenate } from '@ditojs/utils'
60
65
  import { addEvents, combineEvents } from '../utils/event.js'
61
66
  import { getTarget } from '../utils/trigger'
62
67
 
68
+ function isFocusableControl(element) {
69
+ return !!element.matches?.('input, textarea, select, button')
70
+ }
71
+
72
+ function isEditableControl(element) {
73
+ return !!element.matches?.('input, textarea, select')
74
+ }
75
+
63
76
  export default {
64
77
  emits: ['update:show'],
65
78
 
66
79
  props: {
67
80
  trigger: { type: String, default: 'click' },
81
+ // The name of the transition of the popup, or `null` to show it at once.
68
82
  transition: { type: String, default: 'slide' },
69
83
  placement: { type: String, default: 'bottom' },
70
84
  show: { type: Boolean, default: false },
@@ -74,6 +88,8 @@ export default {
74
88
  zIndex: { type: Number, default: 0 },
75
89
  keepInView: { type: Boolean, default: true },
76
90
  hideWhenClickOutside: { type: Boolean, default: true },
91
+ // Whether the popup's first child takes on the width of the target.
92
+ matchTargetWidth: { type: Boolean, default: true },
77
93
  alwaysShow: { type: Boolean, default: false },
78
94
  cover: { type: Boolean, default: false },
79
95
  hideDelay: { type: Number, default: 0 }
@@ -82,12 +98,11 @@ export default {
82
98
  data() {
83
99
  return {
84
100
  showPopup: this.show,
85
- popupPlacement: this.placement,
86
101
  focusEvents: null,
87
102
  closeEvents: null,
88
103
  popupEvents: null,
89
- blurTimer: null,
90
- mouseLeaveTimer: null
104
+ mouseLeaveTimer: null,
105
+ targetResizeObserver: null
91
106
  }
92
107
  },
93
108
 
@@ -110,10 +125,6 @@ export default {
110
125
 
111
126
  popupStyle() {
112
127
  return this.zIndex ? `z-index: ${this.zIndex}` : ''
113
- },
114
-
115
- triggerTarget() {
116
- return getTarget(this)
117
128
  }
118
129
  },
119
130
 
@@ -124,72 +135,195 @@ export default {
124
135
 
125
136
  showPopup(to, from) {
126
137
  if (to ^ from) {
138
+ if (!to && this.trigger === 'focus' && this.isPopupFocused()) {
139
+ // Don't lose the focus with the popup, e.g. after selecting a date in
140
+ // a calendar with the keyboard.
141
+ this.focusTriggerInput()
142
+ }
127
143
  this.$emit('update:show', to)
128
144
  this.$nextTick(() => this.onShowPopup(to))
129
145
  }
146
+ },
147
+
148
+ target() {
149
+ if (this.$refs.trigger) {
150
+ this.addTargetEvents()
151
+ if (this.targetResizeObserver) {
152
+ this.unobserveTargetSize()
153
+ this.observeTargetSize()
154
+ }
155
+ }
156
+ },
157
+
158
+ placement() {
159
+ this.$nextTick(() => this.updatePosition())
130
160
  }
131
161
  },
132
162
 
133
163
  mounted() {
134
- const { trigger, popup } = this.$refs
135
- if (this.trigger === 'focus') {
136
- this.focusEvents = this.addFocusEvents(this.triggerTarget ?? trigger)
137
- }
138
-
139
- if (this.hideWhenClickOutside && !this.alwaysShow) {
140
- // Use 'mouseup' instead of 'click', since click appears to happen after
141
- // the DOM inside of popups could change in a way so that the check
142
- // `popup.contains(event.target)` would fail:
143
- this.closeEvents = addEvents(window, {
144
- mouseup: event => {
145
- if (
146
- this.showPopup &&
147
- !popup.contains(event.target) &&
148
- !trigger.contains(event.target) &&
149
- !this.triggerTarget?.contains(event.target)
150
- ) {
151
- this.showPopup = false
152
- }
153
- },
154
-
155
- blur: () => {
156
- this.showPopup = false
157
- }
158
- })
159
- }
164
+ this.addTargetEvents()
160
165
 
161
- if (this.alwaysShow) {
166
+ if (this.showPopup) {
167
+ // The `showPopup` watcher doesn't see popups that are shown initially.
168
+ this.onShowPopup(true)
169
+ } else if (this.alwaysShow) {
162
170
  this.showPopup = true
163
171
  }
164
172
  },
165
173
 
166
174
  created() {
167
175
  this.showPopup = this.show
176
+ // Whether `focusTriggerInput()` is moving the focus, see there.
177
+ this.isFocusingTriggerInput = false
168
178
  },
169
179
 
170
180
  unmounted() {
171
181
  this.focusEvents?.remove()
172
- this.closeEvents?.remove()
182
+ this.removeCloseEvents()
173
183
  this.popupEvents?.remove()
184
+ this.unobserveTargetSize()
185
+ clearTimeout(this.mouseLeaveTimer)
174
186
  },
175
187
 
176
188
  methods: {
189
+ open() {
190
+ if (!this.disabled) {
191
+ this.showPopup = true
192
+ }
193
+ },
194
+
195
+ close() {
196
+ if (!this.alwaysShow) {
197
+ this.showPopup = false
198
+ }
199
+ },
200
+
201
+ toggle() {
202
+ if (this.showPopup) {
203
+ this.close()
204
+ } else {
205
+ this.open()
206
+ }
207
+ },
208
+
209
+ // Returns the element that the popup is positioned at, resolving a `target`
210
+ // given by ref name, which is only possible once mounted. This is why it
211
+ // can't be a computed property: `$refs` aren't reactive.
212
+ getTargetElement() {
213
+ return getTarget(this) ?? this.$refs.trigger
214
+ },
215
+
177
216
  isPopupFocused() {
178
- return !!this.$refs.popup?.matches(':focus-within')
217
+ return !!this.$refs.popup?.contains(document.activeElement)
218
+ },
219
+
220
+ // Returns whether `element` is part of the trigger, its target or the
221
+ // popup.
222
+ containsElement(element) {
223
+ return (
224
+ !!element && (
225
+ this.$el.contains(element) ||
226
+ this.getTargetElement().contains(element)
227
+ )
228
+ )
229
+ },
230
+
231
+ getTriggerInput() {
232
+ return this.getTargetElement()?.querySelector('input, textarea') ?? null
233
+ },
234
+
235
+ focusTriggerInput() {
236
+ // The input's focus event would open the popup again, see
237
+ // `addFocusEvents()`. Focus events are dispatched synchronously.
238
+ this.isFocusingTriggerInput = true
239
+ try {
240
+ this.getTriggerInput()?.focus()
241
+ } finally {
242
+ this.isFocusingTriggerInput = false
243
+ }
244
+ },
245
+
246
+ addTargetEvents() {
247
+ this.focusEvents?.remove()
248
+ this.focusEvents =
249
+ this.trigger === 'focus'
250
+ ? this.addFocusEvents(this.getTargetElement())
251
+ : null
252
+ },
253
+
254
+ // Adds the events that close the popup while it is shown, see
255
+ // `onShowPopup()`.
256
+ addCloseEvents() {
257
+ if (this.hideWhenClickOutside && !this.alwaysShow && !this.closeEvents) {
258
+ const { trigger, popup } = this.$refs
259
+ // Use 'mouseup' instead of 'click', since click appears to happen after
260
+ // the DOM inside of popups could change in a way so that the check
261
+ // `popup.contains(event.target)` would fail:
262
+ this.closeEvents = addEvents(window, {
263
+ mouseup: event => {
264
+ if (
265
+ this.showPopup &&
266
+ !popup.contains(event.target) &&
267
+ !trigger.contains(event.target) &&
268
+ !this.getTargetElement().contains(event.target)
269
+ ) {
270
+ this.showPopup = false
271
+ }
272
+ },
273
+
274
+ blur: () => {
275
+ this.showPopup = false
276
+ }
277
+ })
278
+ }
279
+ },
280
+
281
+ removeCloseEvents() {
282
+ this.closeEvents?.remove()
283
+ this.closeEvents = null
284
+ },
285
+
286
+ onKeyDown(event) {
287
+ if (event.key === 'Escape' && this.showPopup && !this.alwaysShow) {
288
+ // Don't let Escape also close what contains the trigger, e.g. dialogs.
289
+ event.stopPropagation()
290
+ this.close()
291
+ }
292
+ },
293
+
294
+ // Positions the popup whenever the size of the target changes, which
295
+ // includes the moment it is laid out, also if the popup is shown inside a
296
+ // hidden part of the page that only becomes visible later. The target is
297
+ // observed rather than the popup, since `updatePosition()` resizes the
298
+ // popup, which would trigger the observer again.
299
+ observeTargetSize() {
300
+ if (!this.targetResizeObserver) {
301
+ this.targetResizeObserver = new ResizeObserver(
302
+ () => this.updatePosition()
303
+ )
304
+ this.targetResizeObserver.observe(this.getTargetElement())
305
+ }
306
+ },
307
+
308
+ unobserveTargetSize() {
309
+ this.targetResizeObserver?.disconnect()
310
+ this.targetResizeObserver = null
179
311
  },
180
312
 
181
313
  updatePosition() {
182
314
  const { trigger, popup } = this.$refs
183
- if (this.show && popup.offsetWidth === 0) {
184
- setTimeout(() => this.updatePosition(), 0)
315
+ if (!popup || !this.showPopup || popup.offsetWidth === 0) {
316
+ // Unmounted, hidden, or not laid out yet, see `observeTargetSize()`.
185
317
  return
186
318
  }
187
319
 
188
- const target = this.triggerTarget ?? trigger
189
- // Actually resize the popup's first child, so they can set size limits.
190
- const el = this.target === 'popup' ? trigger : popup.firstElementChild
191
- if (el) {
192
- el.style.width = getComputedStyle(target).width
320
+ const target = this.getTargetElement()
321
+ if (this.matchTargetWidth) {
322
+ // Actually resize the popup's first child, so it can set size limits.
323
+ const el = this.target === 'popup' ? trigger : popup.firstElementChild
324
+ if (el) {
325
+ el.style.width = getComputedStyle(target).width
326
+ }
193
327
  }
194
328
 
195
329
  const bounds = target.getBoundingClientRect()
@@ -200,42 +334,77 @@ export default {
200
334
  const popupWidth = popup.offsetWidth
201
335
  const popupHeight = popup.offsetHeight
202
336
 
203
- let [part1, part2] = this.popupPlacement.split('-') || []
337
+ let [part1, part2] = this.placement.split('-') || []
204
338
  if (this.keepInView) {
205
339
  const winWidth = window.innerWidth
206
340
  const winHeight = window.innerHeight
341
+ const roomAbove = triggerTop
342
+ const roomBelow = winHeight - triggerTop - triggerHeight
343
+ const roomLeft = triggerLeft
344
+ const roomRight = winWidth - triggerLeft - triggerWidth
345
+ // Flips the popup to the other side if it doesn't fit, but only if
346
+ // there's more room there, so it doesn't move further out of view.
347
+ const shouldFlip = (room, otherRoom, size) => (
348
+ room < size && otherRoom > room
349
+ )
350
+
207
351
  if (part1 === 'top') {
208
- if (triggerTop < popupHeight) {
352
+ if (shouldFlip(roomAbove, roomBelow, popupHeight)) {
209
353
  part1 = 'bottom'
210
354
  }
211
355
  } else if (part1 === 'bottom') {
212
- if (winHeight - triggerTop - triggerHeight < popupHeight) {
356
+ if (shouldFlip(roomBelow, roomAbove, popupHeight)) {
213
357
  part1 = 'top'
214
358
  }
215
359
  } else if (part1 === 'left') {
216
- if (triggerLeft < popupWidth) {
360
+ if (shouldFlip(roomLeft, roomRight, popupWidth)) {
217
361
  part1 = 'right'
218
362
  }
219
363
  } else if (part1 === 'right') {
220
- if (winWidth - triggerLeft - triggerWidth < popupWidth) {
364
+ if (shouldFlip(roomRight, roomLeft, popupWidth)) {
221
365
  part1 = 'left'
222
366
  }
223
367
  }
224
368
 
369
+ // The alignments of the popup's edges with the trigger's edges.
225
370
  if (part2 === 'top') {
226
- if (winHeight - triggerTop < popupHeight) {
371
+ if (
372
+ shouldFlip(
373
+ winHeight - triggerTop,
374
+ triggerTop + triggerHeight,
375
+ popupHeight
376
+ )
377
+ ) {
227
378
  part2 = 'bottom'
228
379
  }
229
380
  } else if (part2 === 'bottom') {
230
- if (triggerTop + triggerHeight < popupHeight) {
381
+ if (
382
+ shouldFlip(
383
+ triggerTop + triggerHeight,
384
+ winHeight - triggerTop,
385
+ popupHeight
386
+ )
387
+ ) {
231
388
  part2 = 'top'
232
389
  }
233
390
  } else if (part2 === 'left') {
234
- if (winWidth - triggerLeft < popupWidth) {
391
+ if (
392
+ shouldFlip(
393
+ winWidth - triggerLeft,
394
+ triggerLeft + triggerWidth,
395
+ popupWidth
396
+ )
397
+ ) {
235
398
  part2 = 'right'
236
399
  }
237
400
  } else if (part2 === 'right') {
238
- if (triggerLeft + triggerWidth < popupWidth) {
401
+ if (
402
+ shouldFlip(
403
+ triggerLeft + triggerWidth,
404
+ winWidth - triggerLeft,
405
+ popupWidth
406
+ )
407
+ ) {
239
408
  part2 = 'left'
240
409
  }
241
410
  }
@@ -283,82 +452,109 @@ export default {
283
452
 
284
453
  addFocusEvents(parent) {
285
454
  const targets = parent.querySelectorAll('input, textarea')
286
- let input
455
+ // The trigger's input while it is marked readonly, see `mousedown`, and
456
+ // the events that release it again.
457
+ let lockedInput = null
458
+ let releaseEvents = null
459
+
460
+ const releaseInput = event => {
461
+ const input = lockedInput
462
+ lockedInput = null
463
+ releaseEvents.remove()
464
+ releaseEvents = null
465
+ // Give some time for other events to update input before it becomes
466
+ // editable and still focused again.
467
+ setTimeout(() => {
468
+ input.removeAttribute('readonly')
469
+ // Only bring the focus back from the popup, not if the focus moved
470
+ // elsewhere in the meantime, e.g. by clicking outside.
471
+ if (!isFocusableControl(event.target) && this.isPopupFocused()) {
472
+ input.focus()
473
+ }
474
+ }, 0)
475
+ }
476
+
477
+ // Only close once the focus leaves the trigger, its target and the
478
+ // popup, e.g. when tabbing out, but not when it moves between them, e.g.
479
+ // when tabbing into the popup. Blur events don't bubble, so capture them.
480
+ const onBlur = event => {
481
+ if (!this.containsElement(event.relatedTarget)) {
482
+ this.close()
483
+ }
484
+ }
485
+ parent.addEventListener('blur', onBlur, true)
287
486
 
288
487
  return combineEvents(
289
488
  addEvents(targets, {
290
489
  focus: () => {
291
- this.showPopup = true
292
- clearTimeout(this.blurTimer)
293
- },
294
-
295
- blur: () => {
296
- // Use timeout to allow clicked inputs to grab focus
297
- this.blurTimer = setTimeout(() => {
298
- if (!this.isPopupFocused()) {
299
- this.showPopup = false
300
- }
301
- }, 0)
490
+ if (!this.isFocusingTriggerInput) {
491
+ this.open()
492
+ }
302
493
  }
303
494
  }),
304
495
 
305
496
  addEvents(parent, {
306
497
  mousedown: event => {
307
- if (!event.target.matches('input, textarea, button')) {
498
+ const { target } = event
499
+ const isInPopup = !!this.$refs.popup?.contains(target)
500
+ // Controls take the focus, except for the buttons in the popup, so
501
+ // that the focus stays in the trigger's input when using the mouse.
502
+ const takesFocus = isInPopup
503
+ ? isEditableControl(target)
504
+ : isFocusableControl(target)
505
+ if (!takesFocus) {
308
506
  event.preventDefault()
309
507
  event.stopPropagation()
310
508
  }
311
- if (!event.target.matches('.dito-button-clear')) {
312
- const trigger = this.triggerTarget ?? this.$refs.trigger
313
- // Mark trigger input as readonly so it can't loose focus while
314
- // user does other mouse-activities in popup.
315
- input = trigger.querySelector('input, textarea')
509
+ // Mark the trigger's input as readonly so it can't lose focus
510
+ // while the user does other mouse-activities in the popup, except
511
+ // for the controls in the popup that take the focus themselves,
512
+ // e.g. the fields of a color picker.
513
+ if (!lockedInput && !(isInPopup && takesFocus)) {
514
+ const input = this.getTriggerInput()
316
515
  if (input && !input.hasAttribute('readonly')) {
317
516
  input.setAttribute('readonly', 'true')
318
- } else {
319
- input = null
517
+ lockedInput = input
518
+ // Release the input wherever the mouse is released, also
519
+ // outside of `parent`, e.g. after dragging in a color picker.
520
+ releaseEvents = addEvents(window, { mouseup: releaseInput })
320
521
  }
321
522
  }
322
- },
523
+ }
524
+ }),
323
525
 
324
- mouseup: event => {
325
- if (input) {
326
- // Give some time for other events to update input before it
327
- // becomes editable and still focused again.
328
- setTimeout(() => {
329
- input.removeAttribute('readonly')
330
- // Only bring the focus back from the popup, not if the focus
331
- // moved elsewhere in the meantime, e.g. by clicking outside.
332
- if (
333
- !event.target.matches('input, textarea, button') &&
334
- this.isPopupFocused()
335
- ) {
336
- input.focus()
337
- }
338
- input = null
339
- }, 0)
340
- }
526
+ {
527
+ remove() {
528
+ parent.removeEventListener('blur', onBlur, true)
529
+ releaseEvents?.remove()
530
+ releaseEvents = null
531
+ lockedInput?.removeAttribute('readonly')
532
+ lockedInput = null
341
533
  }
342
- })
534
+ }
343
535
  )
344
536
  },
345
537
 
346
538
  onShowPopup(show) {
539
+ // The popup may have been toggled again or unmounted in the meantime:
540
+ if (show !== this.showPopup || !this.$refs.popup) return
347
541
  if (show) {
348
542
  if (this.trigger === 'focus') {
543
+ this.popupEvents?.remove()
349
544
  this.popupEvents = this.addFocusEvents(this.$refs.popup)
350
545
  }
351
- this.updatePosition()
546
+ this.addCloseEvents()
547
+ this.observeTargetSize()
352
548
  } else {
353
549
  this.popupEvents?.remove()
354
550
  this.popupEvents = null
551
+ this.removeCloseEvents()
552
+ this.unobserveTargetSize()
355
553
  }
356
554
  },
357
555
 
358
556
  onClick() {
359
- if (!this.disabled) {
360
- this.showPopup = true
361
- }
557
+ this.toggle()
362
558
  },
363
559
 
364
560
  onHover(enter) {
@@ -4,6 +4,7 @@ export { default as DitoDatePicker } from './DitoDatePicker.vue'
4
4
  export { default as DitoDateTimePicker } from './DitoDateTimePicker.vue'
5
5
  export { default as DitoIcon } from './DitoIcon.vue'
6
6
  export { default as DitoInput } from './DitoInput.vue'
7
+ export { default as DitoMenuButton } from './DitoMenuButton.vue'
7
8
  export { default as DitoPagination } from './DitoPagination.vue'
8
9
  export { default as DitoSwitch } from './DitoSwitch.vue'
9
10
  export { default as DitoTimePicker } from './DitoTimePicker.vue'
@@ -83,6 +83,13 @@
83
83
  &[disabled] {
84
84
  @extend %button-disabled;
85
85
  }
86
+
87
+ .dito-buttons--round & {
88
+ padding: $button-round-padding;
89
+ min-width: $input-height;
90
+ min-height: $input-height;
91
+ border-radius: 1em;
92
+ }
86
93
  }
87
94
 
88
95
  .dito-buttons {
@@ -101,15 +108,6 @@
101
108
  margin-left: 1.5em;
102
109
  }
103
110
 
104
- &--round {
105
- .dito-button {
106
- padding: $button-round-padding;
107
- min-width: $input-height;
108
- min-height: $input-height;
109
- border-radius: 1em;
110
- }
111
- }
112
-
113
111
  &--small {
114
112
  --button-margin: 1px;
115
113
  }
@@ -10,4 +10,12 @@
10
10
  height: 1em;
11
11
  z-index: 10;
12
12
  cursor: ns-resize;
13
+ // Let pointer drags resize instead of panning the page or selecting text:
14
+ touch-action: none;
15
+ user-select: none;
16
+
17
+ &:focus-visible {
18
+ outline: none;
19
+ box-shadow: $shadow-focus;
20
+ }
13
21
  }
@@ -12,6 +12,12 @@
12
12
  &.dito-input--focus {
13
13
  border-color: $color-active;
14
14
  }
15
+
16
+ // Keep showing errors while the field has focus, e.g. the first invalid
17
+ // field, which is focused after a failed submit:
18
+ .dito-container--has-errors &:is(:focus, :focus-within, .dito-input--focus) {
19
+ border-color: $color-error;
20
+ }
15
21
  }
16
22
 
17
23
  %input-borderless {
@@ -0,0 +1,24 @@
1
+ import { labelize } from '@ditojs/utils'
2
+
3
+ // Returns the attributes of buttons that perform `verb`, e.g. 'delete': the
4
+ // modifier class that styles the verb, and the label of buttons that don't
5
+ // display text, named by the verb and by its `subject` if known, e.g.
6
+ // 'Add Section'. `hasText` states whether the button displays text, which it
7
+ // does by default when `text` is set.
8
+ export function getVerbButtonAttributes({
9
+ verb,
10
+ subject = null,
11
+ text = null,
12
+ hasText = !!text
13
+ }) {
14
+ const label = hasText
15
+ ? null
16
+ : `${labelize(verb)}${subject ? ` ${subject}` : ''}`
17
+ return {
18
+ class: `dito-button--${verb}`,
19
+ ...(label && {
20
+ 'title': label,
21
+ 'aria-label': label
22
+ })
23
+ }
24
+ }
package/src/utils/date.js CHANGED
@@ -14,6 +14,24 @@ export function alterDate(date, overrides = {}) {
14
14
  return new Date(...Object.values({ ...describeDate(date), ...overrides }))
15
15
  }
16
16
 
17
+ // Returns `date` with its part `partName`, e.g. 'month', stepped by `step`.
18
+ // Stepping the year or the month clamps the day to the length of the new
19
+ // month, so that e.g. January 31 plus a month is February 29, not March 2.
20
+ export function stepDate(date, partName, step) {
21
+ const parts = describeDate(date)
22
+ parts[partName] += step
23
+ if (partName === 'year' || partName === 'month') {
24
+ parts.day = Math.min(parts.day, getDaysInMonth(parts.year, parts.month))
25
+ }
26
+ return alterDate(date, parts)
27
+ }
28
+
29
+ function getDaysInMonth(year, month) {
30
+ // Day 0 of the next month is the last day of `month`, and `Date` rolls
31
+ // `month` over into the next or previous year if it is out of range.
32
+ return new Date(year, month + 1, 0).getDate()
33
+ }
34
+
17
35
  export function parseDate(string, {
18
36
  locale = 'en-US',
19
37
  date = true,