@ditojs/ui 3.2.6 → 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,7 +334,7 @@ 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
@@ -318,82 +452,109 @@ export default {
318
452
 
319
453
  addFocusEvents(parent) {
320
454
  const targets = parent.querySelectorAll('input, textarea')
321
- 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)
322
486
 
323
487
  return combineEvents(
324
488
  addEvents(targets, {
325
489
  focus: () => {
326
- this.showPopup = true
327
- clearTimeout(this.blurTimer)
328
- },
329
-
330
- blur: () => {
331
- // Use timeout to allow clicked inputs to grab focus
332
- this.blurTimer = setTimeout(() => {
333
- if (!this.isPopupFocused()) {
334
- this.showPopup = false
335
- }
336
- }, 0)
490
+ if (!this.isFocusingTriggerInput) {
491
+ this.open()
492
+ }
337
493
  }
338
494
  }),
339
495
 
340
496
  addEvents(parent, {
341
497
  mousedown: event => {
342
- 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) {
343
506
  event.preventDefault()
344
507
  event.stopPropagation()
345
508
  }
346
- if (!event.target.matches('.dito-button-clear')) {
347
- const trigger = this.triggerTarget ?? this.$refs.trigger
348
- // Mark trigger input as readonly so it can't loose focus while
349
- // user does other mouse-activities in popup.
350
- 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()
351
515
  if (input && !input.hasAttribute('readonly')) {
352
516
  input.setAttribute('readonly', 'true')
353
- } else {
354
- 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 })
355
521
  }
356
522
  }
357
- },
523
+ }
524
+ }),
358
525
 
359
- mouseup: event => {
360
- if (input) {
361
- // Give some time for other events to update input before it
362
- // becomes editable and still focused again.
363
- setTimeout(() => {
364
- input.removeAttribute('readonly')
365
- // Only bring the focus back from the popup, not if the focus
366
- // moved elsewhere in the meantime, e.g. by clicking outside.
367
- if (
368
- !event.target.matches('input, textarea, button') &&
369
- this.isPopupFocused()
370
- ) {
371
- input.focus()
372
- }
373
- input = null
374
- }, 0)
375
- }
526
+ {
527
+ remove() {
528
+ parent.removeEventListener('blur', onBlur, true)
529
+ releaseEvents?.remove()
530
+ releaseEvents = null
531
+ lockedInput?.removeAttribute('readonly')
532
+ lockedInput = null
376
533
  }
377
- })
534
+ }
378
535
  )
379
536
  },
380
537
 
381
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
382
541
  if (show) {
383
542
  if (this.trigger === 'focus') {
543
+ this.popupEvents?.remove()
384
544
  this.popupEvents = this.addFocusEvents(this.$refs.popup)
385
545
  }
386
- this.updatePosition()
546
+ this.addCloseEvents()
547
+ this.observeTargetSize()
387
548
  } else {
388
549
  this.popupEvents?.remove()
389
550
  this.popupEvents = null
551
+ this.removeCloseEvents()
552
+ this.unobserveTargetSize()
390
553
  }
391
554
  },
392
555
 
393
556
  onClick() {
394
- if (!this.disabled) {
395
- this.showPopup = true
396
- }
557
+ this.toggle()
397
558
  },
398
559
 
399
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'
@@ -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,
@@ -1,24 +1,33 @@
1
1
  import { asArray } from '@ditojs/utils'
2
2
 
3
- export function addEvents(targets, events) {
3
+ // Adds the `events` handlers to all `targets`, and returns an object with a
4
+ // `remove()` method that removes them again. When a `signal` is passed, the
5
+ // handlers are also removed when it aborts, and not added at all when it
6
+ // already has.
7
+ export function addEvents(targets, events, { signal } = {}) {
4
8
  targets =
5
9
  targets instanceof NodeList
6
10
  ? Array.from(targets)
7
11
  : asArray(targets)
8
12
 
9
- for (const [type, handler] of Object.entries(events)) {
10
- for (const target of targets) {
11
- target.addEventListener(type, handler, false)
13
+ const controller = new AbortController()
14
+ if (!signal?.aborted) {
15
+ // Remove the handlers along with `signal`, through a listener that is
16
+ // itself removed when the handlers are removed first:
17
+ signal?.addEventListener('abort', () => controller.abort(), {
18
+ once: true,
19
+ signal: controller.signal
20
+ })
21
+ for (const [type, handler] of Object.entries(events)) {
22
+ for (const target of targets) {
23
+ target.addEventListener(type, handler, { signal: controller.signal })
24
+ }
12
25
  }
13
26
  }
14
27
 
15
28
  return {
16
29
  remove() {
17
- for (const [type, handler] of Object.entries(events)) {
18
- for (const target of targets) {
19
- target.removeEventListener(type, handler, false)
20
- }
21
- }
30
+ controller.abort()
22
31
  }
23
32
  }
24
33
  }
@@ -1,3 +1,4 @@
1
+ export * from './button.js'
1
2
  export * from './date.js'
2
3
  export * from './event.js'
3
4
  export * from './selection.js'