@ditojs/admin 3.2.7 → 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.
Files changed (112) hide show
  1. package/dist/dito-admin.css +1 -1
  2. package/dist/dito-admin.es.js +6011 -5826
  3. package/dist/dito-admin.umd.js +3 -7
  4. package/package.json +18 -17
  5. package/src/DitoAdmin.js +44 -24
  6. package/src/DitoContext.js +28 -2
  7. package/src/DitoTypeComponent.js +1 -0
  8. package/src/appState.js +6 -3
  9. package/src/components/DitoAccount.vue +35 -27
  10. package/src/components/DitoAffixes.vue +9 -2
  11. package/src/components/DitoClipboard.vue +59 -93
  12. package/src/components/DitoConfirmMessage.vue +18 -0
  13. package/src/components/DitoContainer.vue +56 -18
  14. package/src/components/DitoCreateButton.vue +36 -84
  15. package/src/components/DitoDialog.vue +20 -15
  16. package/src/components/DitoDragHandle.vue +44 -0
  17. package/src/components/DitoDraggable.vue +43 -16
  18. package/src/components/DitoEditButtons.vue +48 -24
  19. package/src/components/DitoErrors.vue +9 -56
  20. package/src/components/DitoForm.vue +82 -42
  21. package/src/components/DitoInputAffixes.vue +50 -0
  22. package/src/components/DitoLabel.vue +1 -15
  23. package/src/components/DitoMenu.vue +45 -52
  24. package/src/components/DitoNotifications.vue +38 -14
  25. package/src/components/DitoOptionList.vue +81 -0
  26. package/src/components/DitoOptionsEditButtons.vue +57 -0
  27. package/src/components/DitoPagination.vue +1 -8
  28. package/src/components/DitoPane.vue +51 -39
  29. package/src/components/DitoPanel.vue +12 -26
  30. package/src/components/DitoPanels.vue +11 -5
  31. package/src/components/DitoResizeHandle.vue +47 -0
  32. package/src/components/DitoRoot.vue +113 -320
  33. package/src/components/DitoSchema.vue +72 -63
  34. package/src/components/DitoSchemaInlined.vue +8 -9
  35. package/src/components/DitoScopes.vue +14 -18
  36. package/src/components/DitoSidebar.vue +4 -29
  37. package/src/components/DitoSourceItem.vue +114 -0
  38. package/src/components/DitoTableCell.vue +4 -0
  39. package/src/components/DitoTableHead.vue +15 -17
  40. package/src/components/DitoTabs.vue +25 -0
  41. package/src/components/DitoTrail.vue +25 -25
  42. package/src/components/DitoTreeItem.vue +102 -141
  43. package/src/components/DitoUploadFile.vue +5 -2
  44. package/src/components/DitoView.vue +33 -24
  45. package/src/index.js +4 -1
  46. package/src/mixins/DitoMixin.js +77 -110
  47. package/src/mixins/DomMixin.js +12 -14
  48. package/src/mixins/EmitterMixin.js +3 -2
  49. package/src/mixins/ItemMixin.js +38 -94
  50. package/src/mixins/OptionsMixin.js +38 -25
  51. package/src/mixins/ResourceMixin.js +182 -111
  52. package/src/mixins/RouteMixin.js +61 -44
  53. package/src/mixins/SchemaParentMixin.js +4 -1
  54. package/src/mixins/SourceMixin.js +118 -100
  55. package/src/mixins/TypeMixin.js +42 -40
  56. package/src/mixins/ValidationMixin.js +16 -4
  57. package/src/mixins/ValidatorMixin.js +1 -1
  58. package/src/styles/_settings.scss +4 -0
  59. package/src/styles/_tippy.scss +0 -11
  60. package/src/styles/style.scss +0 -1
  61. package/src/types/DitoTypeButton.vue +11 -14
  62. package/src/types/DitoTypeCheckboxes.vue +35 -17
  63. package/src/types/DitoTypeCode.vue +19 -41
  64. package/src/types/DitoTypeColor.vue +67 -19
  65. package/src/types/DitoTypeDate.vue +11 -14
  66. package/src/types/DitoTypeList.vue +50 -62
  67. package/src/types/DitoTypeMarkup.vue +41 -54
  68. package/src/types/DitoTypeMultiselect.vue +82 -52
  69. package/src/types/DitoTypeNumber.vue +12 -15
  70. package/src/types/DitoTypeObject.vue +4 -38
  71. package/src/types/DitoTypeRadio.vue +19 -6
  72. package/src/types/DitoTypeSection.vue +23 -9
  73. package/src/types/DitoTypeSelect.vue +39 -23
  74. package/src/types/DitoTypeSlider.vue +9 -1
  75. package/src/types/DitoTypeSwitch.vue +1 -1
  76. package/src/types/DitoTypeText.vue +11 -14
  77. package/src/types/DitoTypeTreeList.vue +46 -8
  78. package/src/types/DitoTypeUpload.vue +190 -67
  79. package/src/utils/DataModel.js +368 -146
  80. package/src/utils/FileDropTargets.js +97 -0
  81. package/src/utils/ItemStores.js +31 -0
  82. package/src/utils/ListQuery.js +115 -0
  83. package/src/utils/LoadingTracker.js +76 -0
  84. package/src/utils/SchemaGraph.js +3 -2
  85. package/src/utils/Session.js +151 -0
  86. package/src/utils/ViewRegistry.js +85 -0
  87. package/src/utils/accessor.js +12 -4
  88. package/src/utils/affixes.js +33 -0
  89. package/src/utils/changeOnceEdited.js +33 -0
  90. package/src/utils/clipboard.js +55 -0
  91. package/src/utils/data.js +10 -5
  92. package/src/utils/dialogs.js +59 -0
  93. package/src/utils/dragResize.js +61 -0
  94. package/src/utils/filter.js +36 -2
  95. package/src/utils/html.js +9 -0
  96. package/src/utils/list.js +10 -0
  97. package/src/utils/options.js +4 -0
  98. package/src/utils/registration.js +18 -0
  99. package/src/utils/request.js +108 -13
  100. package/src/utils/route.js +6 -17
  101. package/src/utils/schema/data.js +24 -2
  102. package/src/utils/schema/events.js +19 -0
  103. package/src/utils/schema/item.js +114 -0
  104. package/src/utils/schema/lookup.js +39 -14
  105. package/src/utils/schema/setup.js +5 -5
  106. package/src/utils/schema/validation.js +16 -2
  107. package/src/utils/uid.js +105 -8
  108. package/src/validators/_decimals.js +16 -5
  109. package/types/index.d.ts +168 -41
  110. package/src/mixins/LoadingMixin.js +0 -23
  111. package/src/mixins/PulldownMixin.js +0 -72
  112. package/src/styles/_pulldown.scss +0 -38
@@ -1,6 +1,9 @@
1
1
  <template lang="pug">
2
+ //- The input of the container refers to the errors by `id` through
3
+ //- `aria-describedby`, see `TypeMixin.attributes`.
2
4
  .dito-errors(
3
5
  v-if="errors"
6
+ :id="id"
4
7
  role="alert"
5
8
  )
6
9
  ul
@@ -10,62 +13,13 @@
10
13
  </template>
11
14
 
12
15
  <script>
13
- import tippy from 'tippy.js'
14
16
  import DitoComponent from '../DitoComponent.js'
15
- import { markRaw } from 'vue'
16
17
 
17
18
  // @vue/component
18
19
  export default DitoComponent.component('DitoErrors', {
19
20
  props: {
21
+ id: { type: String, default: null },
20
22
  errors: { type: Array, default: null }
21
- },
22
-
23
- data() {
24
- return {
25
- tip: null
26
- }
27
- },
28
-
29
- watch: {
30
- errors() {
31
- this.$nextTick(this.updateErrors)
32
- }
33
- },
34
-
35
- unmounted() {
36
- this.tip?.destroy()
37
- },
38
-
39
- methods: {
40
- updateErrors() {
41
- let { tip } = this
42
- tip?.hide()
43
- if (this.errors) {
44
- tip = this.tip ??= markRaw(tippy(this.$el.closest('.dito-container')))
45
- tip.setProps({
46
- content: this.errors.join('\n'),
47
- theme: 'error',
48
- trigger: 'manual',
49
- appendTo: 'parent',
50
- placement: 'bottom-start',
51
- animation: 'shift-away-subtle',
52
- popperOptions: {
53
- modifiers: [
54
- {
55
- name: 'flip',
56
- enabled: false
57
- }
58
- ]
59
- },
60
- interactive: true,
61
- hideOnClick: false,
62
- offset: [3, 3], // 1/2 form-spacing
63
- zIndex: 1
64
- })
65
- tip.popper.addEventListener('mousedown', () => tip.hide())
66
- tip.show()
67
- }
68
- }
69
23
  }
70
24
  })
71
25
  </script>
@@ -74,11 +28,10 @@ export default DitoComponent.component('DitoErrors', {
74
28
  @import '../styles/_imports';
75
29
 
76
30
  .dito-errors {
77
- position: absolute;
78
- visibility: hidden;
79
-
80
- ul {
81
- color: $color-error;
82
- }
31
+ // Take a line of its own also in compact containers with wrapping rows.
32
+ width: 100%;
33
+ margin-top: $form-spacing-half;
34
+ color: $color-error;
35
+ white-space: pre-line;
83
36
  }
84
37
  </style>
@@ -16,7 +16,7 @@
16
16
  DitoFormInner(
17
17
  v-show="isActiveRoute"
18
18
  :nested="isNestedRoute"
19
- :label="itemLabel"
19
+ :label="itemLabelText"
20
20
  )
21
21
  //- Prevent implicit submission of the form, for example when typing enter
22
22
  //- in an input field.
@@ -52,7 +52,13 @@
52
52
 
53
53
  <script>
54
54
  import { markRaw } from 'vue'
55
- import { clone, capitalize, parseDataPath, assignDeeply } from '@ditojs/utils'
55
+ import {
56
+ clone,
57
+ capitalize,
58
+ parseDataPath,
59
+ assignDeeply,
60
+ escapeHtml
61
+ } from '@ditojs/utils'
56
62
  import DitoComponent from '../DitoComponent.js'
57
63
  import RouteMixin from '../mixins/RouteMixin.js'
58
64
  import ResourceMixin from '../mixins/ResourceMixin.js'
@@ -61,7 +67,11 @@ import { isObjectSource } from '../utils/schema/structure.js'
61
67
  import { getButtonSchemas } from '../utils/schema/lookup.js'
62
68
  import { getComponentPathByDataPath } from '../utils/schema/data.js'
63
69
  import { resolvePath } from '../utils/path.js'
70
+ import { isPathWithin } from '../utils/route.js'
71
+ import { transferUids } from '../utils/uid.js'
64
72
  import { DataModel } from '../utils/DataModel.js'
73
+ import { transientNote } from '../utils/dialogs.js'
74
+ import { getTextFromHtml } from '../utils/html.js'
65
75
 
66
76
  // @vue/component
67
77
  export default DitoComponent.component('DitoForm', {
@@ -298,6 +308,11 @@ export default DitoComponent.component('DitoForm', {
298
308
 
299
309
  itemLabel() {
300
310
  return this.getItemLabel(this.sourceSchema, this.data, { extended: true })
311
+ },
312
+
313
+ // `itemLabel` is HTML, while the `aria-label` of the form is text:
314
+ itemLabelText() {
315
+ return getTextFromHtml(this.itemLabel)
301
316
  }
302
317
  },
303
318
 
@@ -326,8 +341,11 @@ export default DitoComponent.component('DitoForm', {
326
341
  if (!this.isMutating) {
327
342
  // Edit a copy of the inherited item, so that changes are only
328
343
  // applied through setSourceData(). Copy it again whenever the item
329
- // itself is replaced, e.g. after applying the changes.
330
- this.clonedData = clone(item)
344
+ // itself is replaced, e.g. after applying the changes. The copy
345
+ // keeps the uids of the new items, see `transferUids()`.
346
+ const clonedData = clone(item)
347
+ transferUids(item, clonedData)
348
+ this.clonedData = clonedData
331
349
  }
332
350
  if (
333
351
  item === null &&
@@ -399,11 +417,31 @@ export default DitoComponent.component('DitoForm', {
399
417
  }
400
418
  },
401
419
 
420
+ // Returns the source component that loads the form's item through its
421
+ // resource in the parent route component, e.g. a list in a parent form, or
422
+ // `null` if it isn't rendered, e.g. the list of a view, which renders the
423
+ // form instead. The form's path is within the path of the source.
424
+ getParentSourceComponent() {
425
+ return (
426
+ this.parentRouteComponent?.mainSchemaComponent?.components.find(
427
+ component => (
428
+ component.sourceComponent === component &&
429
+ component.providesData &&
430
+ isPathWithin(this.path, component.path)
431
+ )
432
+ ) ?? null
433
+ )
434
+ },
435
+
402
436
  setSourceData(data) {
403
437
  if (this.sourceData && this.sourceKey !== null) {
404
438
  const { mainSchemaComponent } = this
405
- this.sourceData[this.sourceKey] =
406
- mainSchemaComponent.filterData(data).localData
439
+ const { localData } = mainSchemaComponent.filterData(data)
440
+ // Keep the uids of new items, which key their components and stores,
441
+ // see `transferUids()`. The applied data is processed from the form's
442
+ // data, which holds them since cloning the item.
443
+ transferUids(this.data, localData)
444
+ this.sourceData[this.sourceKey] = localData
407
445
  mainSchemaComponent.onChange()
408
446
  return true
409
447
  }
@@ -469,6 +507,28 @@ export default DitoComponent.component('DitoForm', {
469
507
  }
470
508
 
471
509
  const getVerb = present => this.verbs[this.getSubmitVerb(present)]
510
+ // NOTE: The notifications pass `html`, as `itemLabel` is HTML with
511
+ // escaped item values, see `getItemLabel()`. Other values that they
512
+ // include need to be escaped too.
513
+ const notifySubmitError = (error, message = null) => {
514
+ const verb = getVerb(true)
515
+ const { itemLabel } = this
516
+ this.notify({
517
+ type: 'error',
518
+ error,
519
+ title: 'Request Error',
520
+ html: message
521
+ ? [`Unable to ${verb} ${itemLabel}:`, escapeHtml(message)]
522
+ : `Unable to ${verb} ${itemLabel}.`
523
+ })
524
+ }
525
+ const callbacks = {
526
+ onSuccess: () => this.emitSchemaEvent(this.getSubmitVerb()),
527
+ onError: error =>
528
+ this.emitSchemaEvent('error', {
529
+ context: { error }
530
+ })
531
+ }
472
532
 
473
533
  // Allow buttons to override both method and resource path to submit to:
474
534
  let { method } = this
@@ -482,65 +542,45 @@ export default DitoComponent.component('DitoForm', {
482
542
  let success
483
543
  if (!buttonResource && this.isTransient) {
484
544
  success = await this.submitTransient(button, resource, method, data, {
485
- onSuccess: () => this.emitSchemaEvent(this.getSubmitVerb()),
486
- onError: error =>
487
- this.emitSchemaEvent('error', {
488
- context: { error }
489
- }),
545
+ ...callbacks,
490
546
  notifySuccess: () => {
491
547
  const verb = getVerb(false)
548
+ const { itemLabel } = this
492
549
  this.notify({
493
550
  type: 'info',
494
551
  title: this.isCreating
495
552
  ? `Item ${capitalize(verb)}`
496
553
  : `Change ${capitalize(verb)}`,
497
- text: [
554
+ html: [
498
555
  this.isCreating
499
- ? `${this.itemLabel} was ${verb}.`
500
- : `Changes to ${this.itemLabel} were ${verb}.`,
501
- this.transientNote
556
+ ? `${itemLabel} was ${verb}.`
557
+ : `Changes to ${itemLabel} were ${verb}.`,
558
+ transientNote
502
559
  ]
503
560
  })
504
561
  },
505
- notifyError: error => {
506
- const verb = getVerb(true)
507
- this.notify({
508
- type: 'error',
509
- error,
510
- title: 'Request Error',
511
- text: `Unable to ${verb} ${this.itemLabel}.`
512
- })
513
- }
562
+ notifyError: error => notifySubmitError(error)
514
563
  })
515
564
  } else {
516
565
  success = await this.submitResource(button, resource, method, data, {
566
+ ...callbacks,
517
567
  setData: true,
518
- onSuccess: () => this.emitSchemaEvent(this.getSubmitVerb()),
519
- onError: error =>
520
- this.emitSchemaEvent('error', {
521
- context: { error }
522
- }),
523
568
  notifySuccess: () => {
524
569
  const verb = getVerb(false)
525
570
  this.notify({
526
571
  type: 'success',
527
572
  title: `Successfully ${capitalize(verb)}`,
528
- text: `${this.itemLabel} was ${verb}.`
573
+ html: `${this.itemLabel} was ${verb}.`
529
574
  })
530
575
  },
531
- notifyError: error => {
532
- const verb = getVerb(true)
533
- this.notify({
534
- type: 'error',
535
- error,
536
- title: 'Request Error',
537
- text: [
538
- `Unable to ${verb} ${this.itemLabel}${error ? ':' : ''}`,
539
- error?.message || error
540
- ]
541
- })
542
- }
576
+ notifyError: error =>
577
+ notifySubmitError(error, error?.message || error)
543
578
  })
579
+ if (success) {
580
+ // The item changed on the server, so the source that lists it needs
581
+ // to load it again.
582
+ this.getParentSourceComponent()?.reloadData()
583
+ }
544
584
  }
545
585
  if (success) {
546
586
  this.resetValidation()
@@ -0,0 +1,50 @@
1
+ <template lang="pug">
2
+ //- The prefix or suffix of the input of a type component, see `schema.prefix`
3
+ //- and `schema.suffix`, and `getInputAffixesProps()` for their props. The
4
+ //- suffix also holds the clear button of clearable components, and their info
5
+ //- when they have no label to show it.
6
+ DitoAffixes(
7
+ :items="items"
8
+ :position="position"
9
+ mode="input"
10
+ :absolute="absolute"
11
+ :clearable="clearable"
12
+ :hasValue="hasValue"
13
+ :disabled="disabled"
14
+ :inlineInfo="inlineInfo"
15
+ :parentContext="parentContext"
16
+ @clear="$emit('clear')"
17
+ )
18
+ template(
19
+ v-if="$slots.append"
20
+ #append
21
+ )
22
+ slot(name="append")
23
+ </template>
24
+
25
+ <script>
26
+ import DitoComponent from '../DitoComponent.js'
27
+ import DitoAffixes from './DitoAffixes.vue'
28
+
29
+ // @vue/component
30
+ export default DitoComponent.component('DitoInputAffixes', {
31
+ components: { DitoAffixes },
32
+ emits: ['clear'],
33
+
34
+ props: {
35
+ position: {
36
+ type: String,
37
+ required: true,
38
+ validator: position => ['prefix', 'suffix'].includes(position)
39
+ },
40
+ items: { type: [String, Object, Array], default: null },
41
+ absolute: { type: Boolean, default: false },
42
+ disabled: { type: Boolean, default: false },
43
+ clearable: { type: Boolean, default: false },
44
+ hasValue: { type: Boolean, default: false },
45
+ inlineInfo: { type: String, default: null },
46
+ // The context of the type component, for the callbacks of the items.
47
+ parentContext: { type: Object, required: true }
48
+ }
49
+ })
50
+ </script>
@@ -4,7 +4,6 @@ component.dito-label(
4
4
  :is="collapsible ? 'button' : 'div'"
5
5
  :type="collapsible ? 'button' : null"
6
6
  v-bind="attributes"
7
- :class="{ 'dito-label--active': isActive }"
8
7
  )
9
8
  .dito-chevron(
10
9
  v-if="collapsible"
@@ -76,16 +75,11 @@ export default DitoComponent.component('DitoLabel', {
76
75
  'aria-expanded': !this.collapsed
77
76
  }
78
77
  : {}
79
- },
80
-
81
- isActive() {
82
- return this.appState.activeLabel === this
83
78
  }
84
79
  },
85
80
 
86
81
  methods: {
87
82
  onClick() {
88
- this.appState.activeLabel = this
89
83
  this.$emit('open', this.collapsed)
90
84
  }
91
85
  }
@@ -108,14 +102,6 @@ export default DitoComponent.component('DitoLabel', {
108
102
  min-height: $input-height;
109
103
  margin-right: $form-spacing-half; // When inlined.
110
104
 
111
- &:has(.dito-schema-header) {
112
- // The container's label is used as teleport for a nested section or object
113
- // label. Hide `&__inner`, as it is be duplicated inside the nested label.
114
- > #{$self}__inner {
115
- display: none;
116
- }
117
- }
118
-
119
105
  &:not(:has(+ .dito-tabs)) {
120
106
  // Take full width but only if there aren't also tabs to be centered.
121
107
  flex: 1;
@@ -199,7 +185,7 @@ export default DitoComponent.component('DitoLabel', {
199
185
  }
200
186
  }
201
187
 
202
- &:focus:not(:active, &--active) {
188
+ &:focus-visible {
203
189
  .dito-chevron {
204
190
  -webkit-text-stroke: $border-width $color-active;
205
191
  }
@@ -1,29 +1,33 @@
1
1
  <template lang="pug">
2
- ul.dito-menu(
3
- v-resize="onResize"
4
- :style="{ '--width': width ? `${width}px` : null }"
5
- )
2
+ ul.dito-menu
6
3
  li.dito-menu__item(
7
4
  v-for="item in items"
8
5
  )
9
6
  template(
10
7
  v-if="shouldShowItem(item)"
11
8
  )
12
- a.dito-menu__link(
13
- :href="getItemHref(item)"
14
- :class="{ 'dito-menu__link--active': isActiveItem(item) }"
15
- :aria-current="isActiveItem(item) ? 'page' : null"
16
- @click.prevent.stop="onClickItem(item)"
17
- ) {{ getLabel(item) }}
9
+ RouterLink(
10
+ v-slot="{ href, navigate }"
11
+ :to="getItemRoute(item)"
12
+ custom
13
+ )
14
+ a.dito-menu__link(
15
+ :href="href"
16
+ :class="{ 'dito-menu__link--active': isActiveItem(item) }"
17
+ :aria-current="isActiveItem(item) ? 'page' : null"
18
+ @click="navigate"
19
+ ) {{ getLabel(item) }}
18
20
  DitoMenu.dito-menu__sub(
19
21
  v-if="item.items"
20
22
  :items="item.items"
21
- :path="getItemPath(item, false)"
23
+ :path="getItemPath(item)"
22
24
  )
23
25
  </template>
24
26
 
25
27
  <script>
26
28
  import DitoComponent from '../DitoComponent.js'
29
+ import { isPathWithin } from '../utils/route.js'
30
+ import { getViewLinkPath } from '../utils/schema/lookup.js'
27
31
 
28
32
  // @vue/component
29
33
  export default DitoComponent.component('DitoMenu', {
@@ -38,12 +42,6 @@ export default DitoComponent.component('DitoMenu', {
38
42
  }
39
43
  },
40
44
 
41
- data() {
42
- return {
43
- width: 0
44
- }
45
- },
46
-
47
45
  methods: {
48
46
  shouldShowItem(item) {
49
47
  return (
@@ -54,36 +52,25 @@ export default DitoComponent.component('DitoMenu', {
54
52
  )
55
53
  },
56
54
 
57
- onResize({ contentRect: { width } }) {
58
- if (width) {
59
- this.width = width
60
- }
55
+ getItemPath(item) {
56
+ return `${this.path}/${item.path}`
61
57
  },
62
58
 
63
- getItemPath(item, firstChild) {
64
- const path = item.path ? `${this.path}/${item.path}` : null
65
- return firstChild && path && item.items
66
- ? `${path}${this.getItemPath(Object.values(item.items)[0], false)}`
67
- : path
68
- },
69
-
70
- getItemHref(item) {
71
- const path = this.getItemPath(item, true)
72
- return path ? this.$router.resolve(path).href : null
59
+ getItemRoute(item) {
60
+ // `DitoRoot` only provides the views once their paths are set up.
61
+ // `force` reloads the view even if it is already the current route.
62
+ return {
63
+ path: getViewLinkPath(item, this.getItemPath(item)),
64
+ force: true
65
+ }
73
66
  },
74
67
 
75
68
  isActiveItem(item) {
69
+ const path = this.getItemPath(item)
76
70
  return (
77
- this.$route.path.startsWith(this.getItemPath(item, false)) ||
71
+ isPathWithin(this.$route.path, path) ||
78
72
  item.items && Object.values(item.items).some(this.isActiveItem)
79
73
  )
80
- },
81
-
82
- onClickItem(item) {
83
- const path = this.getItemPath(item, true)
84
- if (path) {
85
- this.$router.push({ path, force: true })
86
- }
87
74
  }
88
75
  }
89
76
  })
@@ -104,23 +91,33 @@ export default DitoComponent.component('DitoMenu', {
104
91
  padding: 0 $menu-spacing;
105
92
 
106
93
  &__item {
94
+ // Scope the anchor of the open sub-menu to its item, see below.
95
+ anchor-scope: --dito-menu-item;
96
+
107
97
  &:has(#{$self}__sub):not(:has(#{$self}__link--active)) {
108
- // Pop-out sub-menus on hover:
109
- &:hover {
98
+ // Pop-out sub-menus on hover and keyboard focus. They are anchored to
99
+ // their item instead of positioned relative to it, as the menu scrolls
100
+ // and would clip them.
101
+ &:hover,
102
+ &:focus-within {
103
+ anchor-name: --dito-menu-item;
104
+
110
105
  > #{$self}__link {
111
106
  background: $color-lightest;
112
107
  }
113
108
 
114
- #{$self}__sub {
109
+ > #{$self}__sub {
115
110
  display: block;
116
111
  position: absolute;
117
- width: var(--width);
112
+ position-anchor: --dito-menu-item;
113
+ top: anchor(top);
114
+ left: calc(anchor(right) + 2 * $menu-spacing);
115
+ width: anchor-size(width);
118
116
  z-index: $z-index-header;
119
- transform: translateX(calc(var(--width) + 2 * $menu-spacing))
120
- translateY(-$item-height);
117
+ box-shadow: $shadow-window;
121
118
 
122
- #{$self}__item:first-child {
123
- #{$self}__link {
119
+ > #{$self}__item:first-child {
120
+ > #{$self}__link {
124
121
  margin-top: 0;
125
122
  }
126
123
  }
@@ -136,10 +133,6 @@ export default DitoComponent.component('DitoMenu', {
136
133
  opacity: 0;
137
134
  }
138
135
  }
139
-
140
- #{$self}__sub {
141
- box-shadow: $shadow-window;
142
- }
143
136
  }
144
137
  }
145
138
  }
@@ -151,7 +144,7 @@ export default DitoComponent.component('DitoMenu', {
151
144
  border-radius: $border-radius;
152
145
  margin-top: $menu-spacing;
153
146
 
154
- &:focus:not(:active, &--active) {
147
+ &:focus-visible {
155
148
  box-shadow: $shadow-focus;
156
149
  }
157
150
 
@@ -4,7 +4,6 @@
4
4
  span
5
5
  .dito-notifications__inner
6
6
  VueNotifications(
7
- ref="notifications"
8
7
  classes="dito-notification"
9
8
  :dangerouslySetInnerHtml="true"
10
9
  position=""
@@ -14,16 +13,24 @@
14
13
 
15
14
  <script>
16
15
  import DitoComponent from '../DitoComponent.js'
17
- import { asArray, stripHtml } from '@ditojs/utils'
16
+ import { asArray, isString, escapeHtml, stripHtml } from '@ditojs/utils'
17
+
18
+ // Detects opening tags like `<a href="…">` or `<b>`, which callers that still
19
+ // pass formatted text through the escaped `text` option would include:
20
+ function isLikelyMarkup(paragraph) {
21
+ return (
22
+ isString(paragraph) &&
23
+ /<[a-z][a-z0-9-]*(\s[^>]*)?\/?>/i.test(paragraph)
24
+ )
25
+ }
18
26
 
19
27
  // @vue/component
20
28
  export default DitoComponent.component('DitoNotifications', {
21
- notifications() {
22
- return this.isMounted && this.$refs.notifications
23
- },
24
-
25
29
  methods: {
26
- notify({ type = 'info', title, text, error, duration } = {}) {
30
+ // Shows a notification with the paragraphs of `text`, which are escaped,
31
+ // or else of `html`, which is rendered as it is, so it must only contain
32
+ // HTML from trusted sources, and escape all values that it includes.
33
+ notify({ type = 'info', title, text, html, error, duration } = {}) {
27
34
  title ||= (
28
35
  {
29
36
  warning: 'Warning',
@@ -33,8 +40,23 @@ export default DitoComponent.component('DitoNotifications', {
33
40
  }[type] ||
34
41
  'Notification'
35
42
  )
36
- text = `<p>${
37
- asArray(text).join('</p> <p>')
43
+ const isHtml = html != null
44
+ // Skip empty paragraphs, e.g. `transientNote` when it's `false`:
45
+ const paragraphs = asArray(isHtml ? html : text).filter(
46
+ paragraph => paragraph != null && paragraph !== false
47
+ )
48
+ if (!isHtml && paragraphs.some(isLikelyMarkup)) {
49
+ console.warn(
50
+ 'notify(): `text` is escaped and seems to contain HTML markup. Use ' +
51
+ 'the `html` option for formatted text, and escape all values that ' +
52
+ 'it includes.'
53
+ )
54
+ }
55
+ const htmlParagraphs = isHtml
56
+ ? paragraphs
57
+ : paragraphs.map(paragraph => escapeHtml(paragraph))
58
+ const content = `<p>${
59
+ htmlParagraphs.join('</p> <p>')
38
60
  }</p>`.replace(/\n|\r\n|\r/g, '<br>')
39
61
  const log = (
40
62
  {
@@ -48,7 +70,7 @@ export default DitoComponent.component('DitoNotifications', {
48
70
  // eslint-disable-next-line no-console
49
71
  console[log](
50
72
  ...[
51
- stripHtml(text),
73
+ isHtml ? stripHtml(content) : paragraphs.join('\n'),
52
74
  ...(type === 'error' && error ? [error] : [])
53
75
  ]
54
76
  )
@@ -59,18 +81,20 @@ export default DitoComponent.component('DitoNotifications', {
59
81
  // amount of milliseconds multiplied with the amount of characters
60
82
  // displayed in the notification, plus 40 (40 + title + message):
61
83
  const { durationFactor = 20 } = notifications
62
- duration ??= (40 + text.length + title.length) * durationFactor
84
+ duration ??= (40 + content.length + title.length) * durationFactor
63
85
  this.$notify({
64
86
  type,
65
- title,
66
- text,
87
+ title: escapeHtml(title),
88
+ text: content,
67
89
  duration: duration === 0 ? -1 : duration // < 0 -> <= 0 = sticky
68
90
  })
69
91
  }
70
92
  },
71
93
 
72
94
  destroyAll() {
73
- this.notifications.destroyAll()
95
+ // `VueNotifications` doesn't expose methods, but closes all its
96
+ // notifications when notified with `clean`:
97
+ this.$notify({ clean: true })
74
98
  }
75
99
  }
76
100
  })