@dcrackel/hematournamentui 1.0.712 → 1.0.714

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 (107) hide show
  1. package/.prettierignore +7 -7
  2. package/.storybook/main.js +3 -1
  3. package/README.md +42 -0
  4. package/dist/HemaTournamentUI-lib.es.js +6856 -6243
  5. package/dist/HemaTournamentUI-lib.umd.js +27 -27
  6. package/dist/hematournamentui-tailwind.css +79 -9
  7. package/package.json +96 -96
  8. package/prettier.config.js +16 -16
  9. package/src/index.js +43 -42
  10. package/src/main.js +1 -1
  11. package/src/mocks/getDEBouts-TableOf128.js +37 -37
  12. package/src/mocks/getDEBouts-TableOf64.js +35 -35
  13. package/src/stories/Atoms/Input/BaseInput.test.js +97 -97
  14. package/src/stories/Atoms/Input/BaseInput.vue +149 -149
  15. package/src/stories/Molecules/Indicators/MissingHemaRatingsId/MissingHemaRatingsId.stories.js +23 -23
  16. package/src/stories/Molecules/Indicators/MissingHemaRatingsId/MissingHemaRatingsId.vue +35 -35
  17. package/src/stories/Molecules/Indicators/RegistrationStatus/RegistrationStatus.vue +30 -30
  18. package/src/stories/Molecules/Menus/HoverActions/HoverActions.test.js +138 -0
  19. package/src/stories/Molecules/Menus/HoverActions/HoverActions.vue +212 -0
  20. package/src/stories/Molecules/Modals/BracketDirectorsModal/BracketDirectorsModal.stories.js +49 -0
  21. package/src/stories/Molecules/Modals/BracketDirectorsModal/BracketDirectorsModal.test.js +129 -0
  22. package/src/stories/Molecules/Modals/BracketDirectorsModal/BracketDirectorsModal.vue +165 -0
  23. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.stories.js +6 -0
  24. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.test.js +18 -18
  25. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.vue +1 -1
  26. package/src/stories/Organisms/Cards/BoutCard/BoutCard.vue +5 -1
  27. package/src/stories/Organisms/Cards/BoutCard/Variants/BoutCardActive/BoutCardActive.vue +19 -10
  28. package/src/stories/Organisms/Cards/BoutCard/Variants/BoutCardInactive/BoutCardInactive.vue +11 -7
  29. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.stories.js +42 -33
  30. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.test.js +76 -45
  31. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.vue +122 -103
  32. package/src/stories/Organisms/Cards/Director/DirectorCard.test.js +82 -0
  33. package/src/stories/Organisms/Cards/Director/DirectorCard.vue +32 -51
  34. package/src/stories/Organisms/Cards/EditBout/EditBout.test.js +51 -51
  35. package/src/stories/Organisms/Cards/EventCard/EventCard.test.js +106 -106
  36. package/src/stories/Organisms/Cards/FencerCard/FencerCard.vue +298 -298
  37. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.stories.js +77 -60
  38. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.test.js +144 -143
  39. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.vue +289 -256
  40. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.stories.js +69 -69
  41. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.vue +80 -80
  42. package/src/stories/Organisms/Cards/WaitlistFencerCard/WaitlistFencerCard.vue +153 -153
  43. package/src/stories/Organisms/ComplexInputs/DropDown/DropDownMenu.vue +4 -0
  44. package/src/stories/Organisms/ComplexInputs/ImageCropper/ImageCropper.stories.js +31 -31
  45. package/src/stories/Organisms/ComplexInputs/ImageCropper/ImageCropper.test.js +109 -109
  46. package/src/stories/Organisms/ComplexInputs/ImageCropper/ImageCropper.vue +192 -192
  47. package/src/stories/Organisms/Containers/Tableau/Tableau.stories.js +1 -0
  48. package/src/stories/Organisms/Containers/Tableau/Tableau.test.js +247 -204
  49. package/src/stories/Organisms/Containers/Tableau/Tableau.vue +89 -15
  50. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.test.js +51 -29
  51. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.vue +63 -46
  52. package/src/stories/Organisms/Containers/Tableau/components/TableauSvgOverlay.test.js +21 -21
  53. package/src/stories/Organisms/Containers/Tableau/components/TableauSvgOverlay.vue +39 -39
  54. package/src/stories/Organisms/Containers/Tableau/layout/TableauLayoutEngine.js +145 -145
  55. package/src/stories/Organisms/Containers/Tableau/layout/TableauLayoutEngine.test.js +64 -64
  56. package/src/stories/Organisms/Containers/Tableau/plannedAssignments.js +36 -57
  57. package/src/stories/Organisms/Containers/Tableau/plannedAssignments.test.js +101 -65
  58. package/src/stories/Organisms/Containers/TableauColumn/TableauColumn.vue +14 -1
  59. package/src/stories/Organisms/Wizards/EditEventWizard/BasicEventInfo/BasicEventInfo.stories.js +52 -52
  60. package/src/stories/Organisms/Wizards/EditEventWizard/BasicEventInfo/BasicEventInfo.vue +10 -3
  61. package/src/stories/Organisms/Wizards/EditEventWizard/DirectElimination/DirectElimination.vue +226 -226
  62. package/src/stories/Organisms/Wizards/EditEventWizard/DirectEliminationPromotion/DirectEliminationPromotion.vue +204 -204
  63. package/src/stories/Organisms/Wizards/EditEventWizard/EditEventWizard.stories.js +137 -137
  64. package/src/stories/Organisms/Wizards/EditEventWizard/EventCustomization/EventCustomization.vue +172 -172
  65. package/src/stories/Organisms/Wizards/EditEventWizard/PoolConfiguration/PoolConfiguration.vue +216 -216
  66. package/src/stories/Organisms/Wizards/EditEventWizard/PriceFields.test.js +79 -79
  67. package/src/stories/Organisms/Wizards/EditEventWizard/RuleSerialization.test.js +29 -29
  68. package/src/stories/Organisms/Wizards/EditEventWizard/WeaponSelection.test.js +138 -0
  69. package/src/stories/Templates/EventManagement/Bracket/Bracket.stories.js +149 -9
  70. package/src/stories/Templates/EventManagement/Bracket/Bracket.test.js +54 -2
  71. package/src/stories/Templates/EventManagement/Bracket/Bracket.vue +77 -8
  72. package/src/stories/Templates/EventManagement/Bracket/BracketDirectorAssignment.test.js +242 -0
  73. package/src/stories/Templates/EventManagement/Bracket/BracketMobile.test.js +131 -0
  74. package/src/stories/Templates/EventManagement/Bracket/BracketPlannedSuggestions.js +50 -39
  75. package/src/stories/Templates/EventManagement/Bracket/BracketStories.test.js +201 -0
  76. package/src/stories/Templates/EventManagement/Bracket/BracketStoryAssignments.js +93 -0
  77. package/src/stories/Templates/EventManagement/Bracket/BracketStoryAssignments.test.js +152 -0
  78. package/src/stories/Templates/EventManagement/Bracket/BracketStoryHarness.js +39 -39
  79. package/src/stories/Templates/EventManagement/Bracket/BracketStoryHarness.test.js +79 -79
  80. package/src/stories/Templates/EventManagement/EditEventInfo/EditEventInfo.stories.js +93 -93
  81. package/src/stories/Templates/EventManagement/EditEventInfo/EditEventInfo.vue +91 -91
  82. package/src/stories/Templates/EventManagement/PoolLive/PoolLive.test.js +121 -121
  83. package/src/stories/Templates/Menus/EditEventsTopMenu/EditEventsTopMenu.stories.js +9 -2
  84. package/src/stories/Templates/Menus/EditEventsTopMenu/EditEventsTopMenu.vue +5 -4
  85. package/src/stories/Templates/PersonManagement/Admin/Administration/Administration.stories.js +81 -81
  86. package/src/stories/Templates/PersonManagement/Admin/Administration/Administration.test.js +39 -39
  87. package/src/stories/Templates/PersonManagement/Admin/Administration/Administration.vue +101 -101
  88. package/src/stories/Templates/PersonManagement/Admin/FencerAdmin/FencerAdmin.stories.js +71 -71
  89. package/src/stories/Templates/PersonManagement/Admin/FencerAdmin/FencerAdmin.vue +86 -86
  90. package/src/stories/Templates/PersonManagement/Admin/MergeAccounts/MergeAccounts.stories.js +86 -86
  91. package/src/stories/Templates/PersonManagement/Admin/MergeAccounts/MergeAccounts.vue +247 -247
  92. package/src/stories/Templates/PersonManagement/Basic/BasicEdit.vue +170 -170
  93. package/src/stories/Templates/PersonManagement/PersonManagement.stories.js +156 -156
  94. package/src/stories/Templates/PersonManagement/PersonManagement.test.js +329 -329
  95. package/src/stories/Templates/PersonManagement/PersonManagement.vue +329 -329
  96. package/src/stories/Templates/PersonManagement/PersonManagementDraft.js +42 -42
  97. package/src/stories/Templates/TournamentManagement/TournamentManagement.stories.js +84 -84
  98. package/src/stories/Templates/TournamentManagement/TournamentManagement.test.js +114 -114
  99. package/src/stories/Templates/TournamentManagement/TournamentManagement.vue +363 -363
  100. package/src/stories/Templates/TournamentManagement/TournamentPageOne/TournamentPageOne.stories.js +116 -116
  101. package/src/stories/Templates/TournamentManagement/TournamentPageOne/TournamentPageOne.vue +307 -307
  102. package/src/stories/Util/Validations/PersonManagementValidations.js +45 -45
  103. package/src/stories/Util/Validations/PriceValidation.js +6 -6
  104. package/src/stories/Util/boutDirectorAssignment.js +46 -0
  105. package/src/stories/Util/bracketDirectors.js +86 -0
  106. package/src/stories/Util/registrationStatus.js +11 -11
  107. package/src/stories/Util/tabs.js +19 -47
@@ -1,35 +1,35 @@
1
- <template>
2
- <span
3
- v-if="!hasHemaRatingsId"
4
- role="img"
5
- aria-label="Missing HEMA Ratings ID"
6
- title="Missing HEMA Ratings ID"
7
- data-testid="missing-hema-ratings-id"
8
- class="inline-flex shrink-0 items-center justify-center text-alarm"
9
- >
10
- <svg class="h-6 w-6 md:h-7 md:w-7" viewBox="0 0 32 32" fill="none" aria-hidden="true">
11
- <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="2" />
12
- <text x="16" y="20" text-anchor="middle" fill="currentColor" class="font-bold" font-size="11">HR</text>
13
- <path d="M7 25 25 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
14
- </svg>
15
- </span>
16
- </template>
17
-
18
- <script>
19
- export default {
20
- name: 'MissingHemaRatingsId',
21
- props: {
22
- person: {
23
- type: Object,
24
- default: null
25
- }
26
- },
27
- computed: {
28
- hasHemaRatingsId() {
29
- const id = this.person?.HemaRatingsId ?? this.person?.HEMARatings?.[0]?.HemaRatingId;
30
- const numericId = Number(id);
31
- return Number.isInteger(numericId) && numericId > 0;
32
- }
33
- }
34
- };
35
- </script>
1
+ <template>
2
+ <span
3
+ v-if="!hasHemaRatingsId"
4
+ role="img"
5
+ aria-label="Missing HEMA Ratings ID"
6
+ title="Missing HEMA Ratings ID"
7
+ data-testid="missing-hema-ratings-id"
8
+ class="inline-flex shrink-0 items-center justify-center text-alarm"
9
+ >
10
+ <svg class="h-6 w-6 md:h-7 md:w-7" viewBox="0 0 32 32" fill="none" aria-hidden="true">
11
+ <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="2" />
12
+ <text x="16" y="20" text-anchor="middle" fill="currentColor" class="font-bold" font-size="11">HR</text>
13
+ <path d="M7 25 25 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
14
+ </svg>
15
+ </span>
16
+ </template>
17
+
18
+ <script>
19
+ export default {
20
+ name: 'MissingHemaRatingsId',
21
+ props: {
22
+ person: {
23
+ type: Object,
24
+ default: null
25
+ }
26
+ },
27
+ computed: {
28
+ hasHemaRatingsId() {
29
+ const id = this.person?.HemaRatingsId ?? this.person?.HEMARatings?.[0]?.HemaRatingId;
30
+ const numericId = Number(id);
31
+ return Number.isInteger(numericId) && numericId > 0;
32
+ }
33
+ }
34
+ };
35
+ </script>
@@ -1,30 +1,30 @@
1
- <template>
2
- <div v-if="statusLabel" class="inline-flex items-center gap-2 whitespace-nowrap">
3
- <span class="sr-only">Registration status:</span>
4
- <BaseIcon icon-name="fa-info-circle" size="xs" color="quaternary" aria-hidden="true" />
5
- <BaseText :text="statusLabel" size="xs" color="quaternary" />
6
- </div>
7
- </template>
8
-
9
- <script>
10
- import BaseIcon from '../../../Atoms/Icon/BaseIcon.vue';
11
- import BaseText from '../../../Atoms/Text/BaseText.vue';
12
-
13
- import { getRegistrationStatusLabel } from '../../../Util/registrationStatus.js';
14
-
15
- export default {
16
- name: 'RegistrationStatus',
17
- components: { BaseIcon, BaseText },
18
- props: {
19
- status: {
20
- type: String,
21
- default: ''
22
- }
23
- },
24
- computed: {
25
- statusLabel() {
26
- return getRegistrationStatusLabel(this.status);
27
- }
28
- }
29
- };
30
- </script>
1
+ <template>
2
+ <div v-if="statusLabel" class="inline-flex items-center gap-2 whitespace-nowrap">
3
+ <span class="sr-only">Registration status:</span>
4
+ <BaseIcon icon-name="fa-info-circle" size="xs" color="quaternary" aria-hidden="true" />
5
+ <BaseText :text="statusLabel" size="xs" color="quaternary" />
6
+ </div>
7
+ </template>
8
+
9
+ <script>
10
+ import BaseIcon from '../../../Atoms/Icon/BaseIcon.vue';
11
+ import BaseText from '../../../Atoms/Text/BaseText.vue';
12
+
13
+ import { getRegistrationStatusLabel } from '../../../Util/registrationStatus.js';
14
+
15
+ export default {
16
+ name: 'RegistrationStatus',
17
+ components: { BaseIcon, BaseText },
18
+ props: {
19
+ status: {
20
+ type: String,
21
+ default: ''
22
+ }
23
+ },
24
+ computed: {
25
+ statusLabel() {
26
+ return getRegistrationStatusLabel(this.status);
27
+ }
28
+ }
29
+ };
30
+ </script>
@@ -0,0 +1,138 @@
1
+ import { mount, DOMWrapper } from '@vue/test-utils';
2
+ import { nextTick } from 'vue';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import HoverActions from './HoverActions.vue';
5
+
6
+ const wrappers = [];
7
+ const actions = [
8
+ { id: 'open', label: 'Open bout' },
9
+ { id: 'assign-director', label: 'Assign director...', description: 'Dave Smith' }
10
+ ];
11
+ const mountMenu = (props = {}) => {
12
+ const wrapper = mount(HoverActions, {
13
+ attachTo: document.body,
14
+ props: { actions, ...props },
15
+ slots: { default: '<button type="button" data-testid="pairing">Pairing</button>' }
16
+ });
17
+ wrappers.push(wrapper);
18
+ return wrapper;
19
+ };
20
+ const getPanel = () => new DOMWrapper(document.querySelector('[data-testid="bout-actions-menu"]'));
21
+ const hoverMenu = async wrapper => {
22
+ await wrapper.trigger('mouseenter');
23
+ vi.advanceTimersByTime(200);
24
+ await nextTick();
25
+ await nextTick();
26
+ };
27
+
28
+ beforeEach(() => vi.useFakeTimers());
29
+ afterEach(() => {
30
+ wrappers.splice(0).forEach(wrapper => wrapper.unmount());
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ describe('HoverActions', () => {
35
+ it('waits briefly before opening and cancels a passing hover', async () => {
36
+ const wrapper = mountMenu();
37
+ await wrapper.trigger('mouseenter');
38
+ vi.advanceTimersByTime(100);
39
+ await nextTick();
40
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
41
+ await wrapper.trigger('mouseleave');
42
+ vi.advanceTimersByTime(300);
43
+ await nextTick();
44
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
45
+ await hoverMenu(wrapper);
46
+ expect(getPanel().text()).toContain('Assign director...');
47
+ });
48
+
49
+ it('keeps the menu open while crossing from the pairing into it', async () => {
50
+ const wrapper = mountMenu();
51
+ await hoverMenu(wrapper);
52
+ await wrapper.trigger('mouseleave');
53
+ vi.advanceTimersByTime(70);
54
+ await getPanel().trigger('mouseenter');
55
+ vi.advanceTimersByTime(250);
56
+ await nextTick();
57
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).not.toBeNull();
58
+ await getPanel().trigger('mouseleave');
59
+ vi.advanceTimersByTime(150);
60
+ await nextTick();
61
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
62
+ });
63
+
64
+ it('supports focus, arrow navigation, and Escape without reopening', async () => {
65
+ const wrapper = mountMenu();
66
+ wrapper.get('[data-testid="pairing"]').element.focus();
67
+ await nextTick();
68
+ await wrapper.get('[data-testid="pairing"]').trigger('keydown', { key: 'ArrowDown' });
69
+ await nextTick();
70
+ expect(document.activeElement.dataset.action).toBe('open');
71
+ await getPanel().trigger('keydown', { key: 'ArrowDown' });
72
+ expect(document.activeElement.dataset.action).toBe('assign-director');
73
+ await getPanel().trigger('keydown', { key: 'Escape' });
74
+ await nextTick();
75
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
76
+ expect(document.activeElement).toBe(wrapper.get('[data-testid="pairing"]').element);
77
+ });
78
+
79
+ it('emits only the chosen action and dismisses the menu', async () => {
80
+ const wrapper = mountMenu();
81
+ await hoverMenu(wrapper);
82
+ await getPanel().get('[data-action="assign-director"]').trigger('click');
83
+ expect(wrapper.emitted('action')).toEqual([['assign-director']]);
84
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
85
+ });
86
+
87
+ it('closes on a direct pairing click, outside click, or viewport scroll', async () => {
88
+ const wrapper = mountMenu();
89
+ await hoverMenu(wrapper);
90
+ await wrapper.get('[data-testid="pairing"]').trigger('click');
91
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
92
+ expect(wrapper.emitted('action')).toBeUndefined();
93
+ await hoverMenu(wrapper);
94
+ document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }));
95
+ await nextTick();
96
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
97
+ await hoverMenu(wrapper);
98
+ window.dispatchEvent(new Event('scroll'));
99
+ await nextTick();
100
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
101
+ });
102
+
103
+ it('positions the panel inside the viewport and opens above a bottom pairing', async () => {
104
+ const wrapper = mountMenu();
105
+ wrapper.element.getBoundingClientRect = () => ({
106
+ left: window.innerWidth - 10,
107
+ top: window.innerHeight - 40,
108
+ bottom: window.innerHeight
109
+ });
110
+ await hoverMenu(wrapper);
111
+ getPanel().element.getBoundingClientRect = () => ({ width: 144, height: 80 });
112
+ wrapper.vm.positionPanel();
113
+ await nextTick();
114
+ expect(Number.parseFloat(getPanel().element.style.left)).toBe(window.innerWidth - 148);
115
+ expect(Number.parseFloat(getPanel().element.style.top)).toBe(window.innerHeight - 124);
116
+ });
117
+
118
+ it('allows only one open dropdown and cleans up on unmount', async () => {
119
+ const first = mountMenu();
120
+ const second = mountMenu();
121
+ await hoverMenu(first);
122
+ await hoverMenu(second);
123
+ expect(first.vm.isOpen).toBe(false);
124
+ expect(document.querySelectorAll('[data-testid="bout-actions-menu"]')).toHaveLength(1);
125
+ second.unmount();
126
+ wrappers.pop();
127
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
128
+ });
129
+
130
+ it('removes unavailable actions as props change', async () => {
131
+ const wrapper = mountMenu();
132
+ await hoverMenu(wrapper);
133
+ await wrapper.setProps({ actions: [actions[0]] });
134
+ expect(getPanel().find('[data-action="assign-director"]').exists()).toBe(false);
135
+ await wrapper.setProps({ actions: [] });
136
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
137
+ });
138
+ });
@@ -0,0 +1,212 @@
1
+ <template>
2
+ <div
3
+ ref="trigger"
4
+ data-testid="bout-actions-trigger"
5
+ :tabindex="focusable && actions.length ? 0 : undefined"
6
+ :role="focusable && actions.length ? 'button' : undefined"
7
+ :aria-label="focusable ? label : undefined"
8
+ :aria-haspopup="focusable && actions.length ? 'menu' : undefined"
9
+ :aria-expanded="focusable && actions.length ? isOpen : undefined"
10
+ @mouseenter="queueOpen"
11
+ @mouseleave="queueClose"
12
+ @focusin="handleTriggerFocus"
13
+ @focusout="handleFocusOut"
14
+ @keydown="handleTriggerKeydown"
15
+ @click="closeMenu()"
16
+ >
17
+ <slot :isOpen="isOpen" />
18
+ <Teleport to="body">
19
+ <div
20
+ v-if="isOpen && actions.length"
21
+ ref="panel"
22
+ role="menu"
23
+ :aria-label="label"
24
+ data-testid="bout-actions-menu"
25
+ class="fixed z-[100] min-w-36 rounded-md border border-dropdownSelect bg-white p-1 text-primaryHighlight shadow-lg"
26
+ :style="panelStyle"
27
+ @mouseenter="keepOpen"
28
+ @mouseleave="queueClose"
29
+ @focusout="handleFocusOut"
30
+ @keydown="handlePanelKeydown"
31
+ @click.stop
32
+ >
33
+ <template v-for="action in actions" :key="action.id">
34
+ <div
35
+ v-if="action.description"
36
+ role="presentation"
37
+ data-testid="action-description"
38
+ class="cursor-default px-3 pt-2 text-left text-xs font-semibold text-bright"
39
+ :style="{ color: action.descriptionColor || undefined }"
40
+ >
41
+ {{ action.description }}
42
+ </div>
43
+ <button
44
+ type="button"
45
+ role="menuitem"
46
+ :data-action="action.id"
47
+ class="block w-full rounded px-3 py-2 text-left text-xs hover:bg-dropdownSelect focus-visible:bg-dropdownSelect focus-visible:outline-none disabled:opacity-50"
48
+ :disabled="action.disabled"
49
+ @click.stop="selectAction(action.id)"
50
+ >
51
+ {{ action.label }}
52
+ </button>
53
+ </template>
54
+ </div>
55
+ </Teleport>
56
+ </div>
57
+ </template>
58
+
59
+ <script>
60
+ let activeMenu = null;
61
+
62
+ export default {
63
+ name: 'HoverActions',
64
+ emits: ['action'],
65
+ props: {
66
+ actions: { type: Array, default: () => [] },
67
+ label: { type: String, default: 'Bout actions' },
68
+ focusable: { type: Boolean, default: false }
69
+ },
70
+ data() {
71
+ return {
72
+ isOpen: false,
73
+ isHovered: false,
74
+ openTimer: null,
75
+ closeTimer: null,
76
+ ignoreFocus: false,
77
+ triggerFocus: null,
78
+ panelStyle: { top: '0px', left: '0px' }
79
+ };
80
+ },
81
+ watch: {
82
+ actions: {
83
+ deep: true,
84
+ handler() {
85
+ if (!this.actions.length) this.closeMenu();
86
+ if (this.isOpen) this.$nextTick(this.positionPanel);
87
+ }
88
+ }
89
+ },
90
+ beforeUnmount() {
91
+ this.closeMenu();
92
+ },
93
+ methods: {
94
+ clearTimers() {
95
+ clearTimeout(this.openTimer);
96
+ clearTimeout(this.closeTimer);
97
+ this.openTimer = null;
98
+ this.closeTimer = null;
99
+ },
100
+ queueOpen() {
101
+ this.keepOpen();
102
+ if (this.isOpen || !this.actions.length) return;
103
+ this.openTimer = setTimeout(this.openMenu, 200);
104
+ },
105
+ keepOpen() {
106
+ this.isHovered = true;
107
+ this.clearTimers();
108
+ },
109
+ queueClose() {
110
+ this.isHovered = false;
111
+ this.clearTimers();
112
+ this.closeTimer = setTimeout(this.closeWhenOutside, 150);
113
+ },
114
+ containsElement(element) {
115
+ return Boolean(element && (this.$refs.trigger?.contains(element) || this.$refs.panel?.contains(element)));
116
+ },
117
+ closeWhenOutside() {
118
+ if (this.isHovered || this.containsElement(document.activeElement)) return;
119
+ this.closeMenu();
120
+ },
121
+ async openMenu() {
122
+ this.clearTimers();
123
+ if (this.isOpen || !this.actions.length) return;
124
+ activeMenu?.closeMenu();
125
+ activeMenu = this;
126
+ this.isOpen = true;
127
+ document.addEventListener('pointerdown', this.handleOutsidePointer);
128
+ window.addEventListener('scroll', this.handleViewportChange, true);
129
+ window.addEventListener('resize', this.handleViewportChange);
130
+ await this.$nextTick();
131
+ this.positionPanel();
132
+ },
133
+ positionPanel() {
134
+ const trigger = this.$refs.trigger?.getBoundingClientRect();
135
+ const panel = this.$refs.panel?.getBoundingClientRect();
136
+ if (!trigger || !panel) return;
137
+ const left = Math.max(4, Math.min(trigger.left, window.innerWidth - panel.width - 4));
138
+ const below = trigger.bottom + 4;
139
+ const top = below + panel.height <= window.innerHeight - 4 ? below : Math.max(4, trigger.top - panel.height - 4);
140
+ this.panelStyle = { top: top + 'px', left: left + 'px' };
141
+ },
142
+ closeMenu(restoreFocus = false) {
143
+ this.clearTimers();
144
+ this.isOpen = false;
145
+ if (activeMenu === this) activeMenu = null;
146
+ document.removeEventListener('pointerdown', this.handleOutsidePointer);
147
+ window.removeEventListener('scroll', this.handleViewportChange, true);
148
+ window.removeEventListener('resize', this.handleViewportChange);
149
+ if (!restoreFocus) return;
150
+ this.ignoreFocus = true;
151
+ const focusTarget =
152
+ this.triggerFocus || this.$refs.trigger?.querySelector('button:not(:disabled)') || this.$refs.trigger;
153
+ focusTarget?.focus();
154
+ this.ignoreFocus = false;
155
+ },
156
+ handleViewportChange(event) {
157
+ if (this.$refs.panel?.contains(event.target)) return;
158
+ this.closeMenu();
159
+ },
160
+ handleOutsidePointer(event) {
161
+ if (!this.containsElement(event.target)) this.closeMenu();
162
+ },
163
+ handleTriggerFocus(event) {
164
+ if (this.ignoreFocus) return;
165
+ this.triggerFocus = event.target;
166
+ this.openMenu();
167
+ },
168
+ handleFocusOut(event) {
169
+ if (!this.containsElement(event.relatedTarget)) this.queueClose();
170
+ },
171
+ async focusFirstAction() {
172
+ await this.openMenu();
173
+ this.$refs.panel?.querySelector('button:not(:disabled)')?.focus();
174
+ },
175
+ handleTriggerKeydown(event) {
176
+ if (event.key === 'Escape') {
177
+ event.preventDefault();
178
+ event.stopPropagation();
179
+ this.closeMenu(true);
180
+ return;
181
+ }
182
+ const isWrapperActivation = event.target === this.$refs.trigger && ['Enter', ' '].includes(event.key);
183
+ if (event.key !== 'ArrowDown' && !isWrapperActivation) return;
184
+ event.preventDefault();
185
+ event.stopPropagation();
186
+ this.focusFirstAction();
187
+ },
188
+ handlePanelKeydown(event) {
189
+ const handlers = {
190
+ Escape: () => this.closeMenu(true),
191
+ ArrowDown: () => this.moveActionFocus(1),
192
+ ArrowUp: () => this.moveActionFocus(-1)
193
+ };
194
+ const handler = handlers[event.key];
195
+ if (!handler) return;
196
+ event.preventDefault();
197
+ event.stopPropagation();
198
+ handler();
199
+ },
200
+ moveActionFocus(direction) {
201
+ const buttons = Array.from(this.$refs.panel.querySelectorAll('button:not(:disabled)'));
202
+ if (!buttons.length) return;
203
+ const currentIndex = buttons.indexOf(document.activeElement);
204
+ buttons[(currentIndex + direction + buttons.length) % buttons.length].focus();
205
+ },
206
+ selectAction(actionId) {
207
+ this.closeMenu(true);
208
+ this.$emit('action', actionId);
209
+ }
210
+ }
211
+ };
212
+ </script>
@@ -0,0 +1,49 @@
1
+ import { ref, watch } from 'vue';
2
+ import BracketDirectorsModal from './BracketDirectorsModal.vue';
3
+ import {
4
+ availableDirectors,
5
+ availableRings,
6
+ plannedDirectingStations
7
+ } from '../../../Templates/EventManagement/Bracket/BracketPlannedSuggestions.js';
8
+
9
+ export default {
10
+ title: 'Molecules/Modals/BracketDirectorsModal',
11
+ component: BracketDirectorsModal,
12
+ args: { show: true, availableDirectors, rings: availableRings, stations: plannedDirectingStations.slice(0, 2) },
13
+ argTypes: {
14
+ show: { control: 'boolean' },
15
+ availableDirectors: { control: 'object' },
16
+ rings: { control: 'object' },
17
+ stations: { control: 'object' }
18
+ },
19
+ render: args => ({
20
+ components: { BracketDirectorsModal },
21
+ setup() {
22
+ const show = ref(args.show);
23
+ const stations = ref(args.stations);
24
+ watch(
25
+ () => args.show,
26
+ value => {
27
+ show.value = value;
28
+ }
29
+ );
30
+ watch(
31
+ () => args.stations,
32
+ value => {
33
+ stations.value = value;
34
+ },
35
+ { deep: true }
36
+ );
37
+ function saveStations(value) {
38
+ stations.value = value;
39
+ }
40
+ return { args, show, stations, saveStations };
41
+ },
42
+ template:
43
+ '<div><button type="button" class="rounded bg-primary px-4 py-2 text-white" @click="show = true">Manage directors</button><BracketDirectorsModal v-bind="args" v-model:show="show" :stations="stations" @submit:stations="saveStations" /></div>'
44
+ })
45
+ };
46
+ export const StaffAndPoolDefaults = {};
47
+ export const NoSelectedDirectors = { args: { stations: [] } };
48
+ export const NoAvailableStaff = { args: { stations: [], availableDirectors: [] } };
49
+ export const NoRings = { args: { rings: [] } };
@@ -0,0 +1,129 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { nextTick } from 'vue';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import BracketDirectorsModal from './BracketDirectorsModal.vue';
5
+
6
+ const directors = [
7
+ { PersonId: 1, DisplayName: 'Dave Smith', Color: '#2563eb', DefaultRingId: 1 },
8
+ { PersonId: 2, DisplayName: 'Terry Jones', Color: '#dc2626' }
9
+ ];
10
+ const rings = [
11
+ { RingId: 1, RingName: 'Ring 1' },
12
+ { RingId: 2, RingName: 'Ring 2' },
13
+ { RingId: 3, RingName: 'Ring 3' }
14
+ ];
15
+ const station = {
16
+ RingId: 1,
17
+ RingName: 'Ring 1',
18
+ Color: '#2563eb',
19
+ Director: { PersonId: 1, DisplayName: 'Dave Smith' }
20
+ };
21
+ const wrappers = [];
22
+ const mountModal = async (props = {}) => {
23
+ const wrapper = mount(BracketDirectorsModal, {
24
+ attachTo: document.body,
25
+ global: { stubs: { Teleport: true } },
26
+ props: {
27
+ show: true,
28
+ availableDirectors: structuredClone(directors),
29
+ rings: structuredClone(rings),
30
+ stations: [structuredClone(station)],
31
+ ...props
32
+ }
33
+ });
34
+ wrappers.push(wrapper);
35
+ await nextTick();
36
+ return wrapper;
37
+ };
38
+ const dialog = () => document.querySelector('[data-testid="bracket-directors-modal"]');
39
+ const checkbox = (wrapper, id) => wrapper.get('[data-director-id="' + id + '"]');
40
+ const ringSelect = (wrapper, id) => wrapper.get('[data-ring-director-id="' + id + '"]');
41
+ afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()));
42
+
43
+ describe('BracketDirectorsModal', () => {
44
+ it('starts from supplied DE assignments and adds a director who has no pool assignment', async () => {
45
+ const wrapper = await mountModal();
46
+ expect(dialog().open).toBe(true);
47
+ expect(checkbox(wrapper, 1).element.checked).toBe(true);
48
+ expect(checkbox(wrapper, 2).element.checked).toBe(false);
49
+ const before = JSON.parse(JSON.stringify(wrapper.props()));
50
+ await checkbox(wrapper, 2).setValue(true);
51
+ await ringSelect(wrapper, 2).setValue('2');
52
+ await wrapper.get('form').trigger('submit');
53
+ const payload = wrapper.emitted('submit:stations')[0][0];
54
+ expect(payload.map(item => [item.Director.PersonId, item.RingId, item.Color])).toEqual([
55
+ [1, 1, '#2563eb'],
56
+ [2, 2, '#dc2626']
57
+ ]);
58
+ expect(wrapper.props()).toEqual(before);
59
+ payload[0].Director.DisplayName = 'Changed';
60
+ expect(wrapper.props('stations')[0].Director.DisplayName).toBe('Dave Smith');
61
+ expect(wrapper.emitted('update:show')).toEqual([[false]]);
62
+ });
63
+
64
+ it('removes directors by unchecking them and can save an empty roster', async () => {
65
+ const wrapper = await mountModal();
66
+ await checkbox(wrapper, 1).setValue(false);
67
+ await wrapper.get('form').trigger('submit');
68
+ expect(wrapper.emitted('submit:stations')).toEqual([[[]]]);
69
+ expect(wrapper.props('stations')).toHaveLength(1);
70
+ });
71
+
72
+ it('allows a draft ring swap and requires unique rings before saving', async () => {
73
+ const second = {
74
+ RingId: 2,
75
+ RingName: 'Ring 2',
76
+ Color: '#dc2626',
77
+ Director: { PersonId: 2, DisplayName: 'Terry Jones' }
78
+ };
79
+ const wrapper = await mountModal({ stations: [station, second] });
80
+ await ringSelect(wrapper, 1).setValue('2');
81
+ expect(wrapper.get('[role="alert"]').text()).toContain('different ring');
82
+ expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined();
83
+ await wrapper.get('form').trigger('submit');
84
+ expect(wrapper.emitted('submit:stations')).toBeUndefined();
85
+ await ringSelect(wrapper, 2).setValue('1');
86
+ await wrapper.get('form').trigger('submit');
87
+ expect(
88
+ wrapper.emitted('submit:stations')[0][0].map(item => [item.Director.PersonId, item.RingId, item.Color])
89
+ ).toEqual([
90
+ [1, 2, '#2563eb'],
91
+ [2, 1, '#dc2626']
92
+ ]);
93
+ });
94
+
95
+ it('cancels without emitting changes and discards the draft when reopened', async () => {
96
+ const wrapper = await mountModal();
97
+ await checkbox(wrapper, 1).setValue(false);
98
+ await wrapper.get('[aria-label="Close director assignments"]').trigger('click');
99
+ expect(wrapper.emitted('submit:stations')).toBeUndefined();
100
+ await wrapper.setProps({ show: false });
101
+ expect(dialog().open).toBe(false);
102
+ await wrapper.setProps({ show: true });
103
+ expect(checkbox(wrapper, 1).element.checked).toBe(true);
104
+ await wrapper.get('dialog').trigger('cancel');
105
+ expect(wrapper.emitted('update:show')).toEqual([[false], [false]]);
106
+ });
107
+
108
+ it('accepts staff added while open without losing input drafts and reconciles parent-owned station changes', async () => {
109
+ const wrapper = await mountModal();
110
+ await checkbox(wrapper, 2).setValue(true);
111
+ await ringSelect(wrapper, 2).setValue('2');
112
+ await wrapper.setProps({ availableDirectors: [...directors, { PersonId: 3, DisplayName: 'Michael Cord' }] });
113
+ expect(checkbox(wrapper, 2).element.checked).toBe(true);
114
+ expect(ringSelect(wrapper, 2).element.value).toBe('2');
115
+ expect(checkbox(wrapper, 3).element.checked).toBe(false);
116
+ await wrapper.setProps({ stations: [] });
117
+ expect(checkbox(wrapper, 1).element.checked).toBe(false);
118
+ expect(checkbox(wrapper, 2).element.checked).toBe(false);
119
+ });
120
+
121
+ it('degrades gracefully for missing staff or rings and includes previously selected directors', async () => {
122
+ const wrapper = await mountModal({ availableDirectors: [null, {}, { PersonId: 0 }], rings: [] });
123
+ expect(checkbox(wrapper, 1).element.checked).toBe(true);
124
+ expect(wrapper.get('[role="alert"]').text()).toContain('Choose a ring');
125
+ expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined();
126
+ await wrapper.setProps({ stations: [] });
127
+ expect(wrapper.text()).toContain('No directors available.');
128
+ });
129
+ });