@genesislcap/pbc-notify-ui 15.8.3 → 15.9.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.
Files changed (102) hide show
  1. package/README.md +39 -0
  2. package/custom-elements-manifest.config.js +5 -0
  3. package/dist/custom-elements.json +1771 -321
  4. package/dist/dts/components/components.d.ts.map +1 -1
  5. package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts +2 -0
  6. package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts.map +1 -1
  7. package/dist/dts/components/foundation-inbox/inbox.styles.d.ts.map +1 -1
  8. package/dist/dts/components/foundation-inbox/inbox.template.d.ts.map +1 -1
  9. package/dist/dts/components/foundation-inbox/inbox.types.d.ts +4 -1
  10. package/dist/dts/components/foundation-inbox/inbox.types.d.ts.map +1 -1
  11. package/dist/dts/components/foundation-notification-dashboard/alert-sound-attachment.d.ts +86 -0
  12. package/dist/dts/components/foundation-notification-dashboard/alert-sound-attachment.d.ts.map +1 -0
  13. package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.d.ts +39 -1
  14. package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.d.ts.map +1 -1
  15. package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.template.d.ts.map +1 -1
  16. package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.types.d.ts +8 -0
  17. package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.types.d.ts.map +1 -1
  18. package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-management.d.ts.map +1 -1
  19. package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.d.ts +41 -1
  20. package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.d.ts.map +1 -1
  21. package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.template.d.ts.map +1 -1
  22. package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.types.d.ts +2 -1
  23. package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.types.d.ts.map +1 -1
  24. package/dist/dts/components/foundation-notification-dashboard/components/templates/template-management.d.ts.map +1 -1
  25. package/dist/dts/components/foundation-notification-dashboard/styles/dynamic-rule.styles.d.ts.map +1 -1
  26. package/dist/dts/components/foundation-sounds/foundation-sounds-panel.d.ts +73 -0
  27. package/dist/dts/components/foundation-sounds/foundation-sounds-panel.d.ts.map +1 -0
  28. package/dist/dts/components/foundation-sounds/foundation-sounds-panel.styles.d.ts +2 -0
  29. package/dist/dts/components/foundation-sounds/foundation-sounds-panel.styles.d.ts.map +1 -0
  30. package/dist/dts/components/foundation-sounds/foundation-sounds-panel.template.d.ts +3 -0
  31. package/dist/dts/components/foundation-sounds/foundation-sounds-panel.template.d.ts.map +1 -0
  32. package/dist/dts/components/foundation-sounds/foundation-sounds.d.ts +34 -0
  33. package/dist/dts/components/foundation-sounds/foundation-sounds.d.ts.map +1 -0
  34. package/dist/dts/components/foundation-sounds/foundation-sounds.styles.d.ts +3 -0
  35. package/dist/dts/components/foundation-sounds/foundation-sounds.styles.d.ts.map +1 -0
  36. package/dist/dts/index.d.ts +4 -0
  37. package/dist/dts/index.d.ts.map +1 -1
  38. package/dist/dts/react.d.ts +31 -11
  39. package/dist/dts/services/inbox.service.d.ts +6 -0
  40. package/dist/dts/services/inbox.service.d.ts.map +1 -1
  41. package/dist/dts/services/rule.service.d.ts +2 -1
  42. package/dist/dts/services/rule.service.d.ts.map +1 -1
  43. package/dist/dts/utils/alert-sound.d.ts +177 -0
  44. package/dist/dts/utils/alert-sound.d.ts.map +1 -0
  45. package/dist/dts/utils/alert-sound.test.d.ts +2 -0
  46. package/dist/dts/utils/alert-sound.test.d.ts.map +1 -0
  47. package/dist/esm/components/components.js +4 -0
  48. package/dist/esm/components/foundation-inbox/inbox-base/inbox-base.js +6 -0
  49. package/dist/esm/components/foundation-inbox/inbox.styles.js +15 -0
  50. package/dist/esm/components/foundation-inbox/inbox.template.js +16 -0
  51. package/dist/esm/components/foundation-inbox/inbox.types.js +1 -0
  52. package/dist/esm/components/foundation-notification-dashboard/alert-sound-attachment.js +218 -0
  53. package/dist/esm/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.js +17 -2
  54. package/dist/esm/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.template.js +3 -0
  55. package/dist/esm/components/foundation-notification-dashboard/components/rules/rule-management.js +5 -1
  56. package/dist/esm/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.js +23 -2
  57. package/dist/esm/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.template.js +3 -0
  58. package/dist/esm/components/foundation-notification-dashboard/components/templates/template-management.js +8 -1
  59. package/dist/esm/components/foundation-notification-dashboard/styles/dynamic-rule.styles.js +31 -1
  60. package/dist/esm/components/foundation-sounds/foundation-sounds-panel.js +283 -0
  61. package/dist/esm/components/foundation-sounds/foundation-sounds-panel.styles.js +200 -0
  62. package/dist/esm/components/foundation-sounds/foundation-sounds-panel.template.js +179 -0
  63. package/dist/esm/components/foundation-sounds/foundation-sounds.js +206 -0
  64. package/dist/esm/components/foundation-sounds/foundation-sounds.styles.js +7 -0
  65. package/dist/esm/index.js +3 -0
  66. package/dist/esm/services/inbox.service.js +6 -2
  67. package/dist/esm/services/rule.service.js +35 -0
  68. package/dist/esm/utils/alert-sound.js +955 -0
  69. package/dist/esm/utils/alert-sound.test.js +25 -0
  70. package/dist/esm/utils/index.js +3 -0
  71. package/dist/pbc-notify-ui.api.json +2859 -332
  72. package/dist/pbc-notify-ui.d.ts +281 -3
  73. package/dist/react.cjs +19 -5
  74. package/dist/react.mjs +16 -4
  75. package/dist/tsconfig.tsbuildinfo +1 -1
  76. package/package.json +23 -23
  77. package/src/components/components.ts +4 -0
  78. package/src/components/foundation-inbox/inbox-base/inbox-base.ts +7 -0
  79. package/src/components/foundation-inbox/inbox.styles.ts +15 -0
  80. package/src/components/foundation-inbox/inbox.template.ts +16 -0
  81. package/src/components/foundation-inbox/inbox.types.ts +3 -0
  82. package/src/components/foundation-notification-dashboard/alert-sound-attachment.ts +283 -0
  83. package/src/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.template.ts +3 -0
  84. package/src/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.ts +21 -2
  85. package/src/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.types.ts +9 -0
  86. package/src/components/foundation-notification-dashboard/components/rules/rule-management.ts +5 -1
  87. package/src/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.template.ts +3 -0
  88. package/src/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.ts +25 -2
  89. package/src/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.types.ts +2 -1
  90. package/src/components/foundation-notification-dashboard/components/templates/template-management.ts +8 -1
  91. package/src/components/foundation-notification-dashboard/styles/dynamic-rule.styles.ts +31 -1
  92. package/src/components/foundation-sounds/foundation-sounds-panel.styles.ts +201 -0
  93. package/src/components/foundation-sounds/foundation-sounds-panel.template.ts +188 -0
  94. package/src/components/foundation-sounds/foundation-sounds-panel.ts +280 -0
  95. package/src/components/foundation-sounds/foundation-sounds.styles.ts +8 -0
  96. package/src/components/foundation-sounds/foundation-sounds.ts +222 -0
  97. package/src/index.ts +8 -0
  98. package/src/services/inbox.service.ts +12 -2
  99. package/src/services/rule.service.ts +37 -1
  100. package/src/utils/alert-sound.test.ts +32 -0
  101. package/src/utils/alert-sound.ts +1135 -0
  102. package/src/utils/index.ts +3 -0
@@ -0,0 +1,201 @@
1
+ import { css } from '@genesislcap/web-core';
2
+ import { scrollbarStyles } from '../../styles/scrollbar.styles';
3
+
4
+ export const FoundationSoundsPanelStyles = css`
5
+ :host {
6
+ display: flex;
7
+ flex-direction: column;
8
+ height: 100%;
9
+ min-height: 0;
10
+ width: 100%;
11
+ }
12
+
13
+ .sounds-controls {
14
+ display: flex;
15
+ flex-direction: column;
16
+ flex: 1;
17
+ min-height: 0;
18
+ gap: 12px;
19
+ padding: calc(var(--design-unit) * 3px);
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ .sounds-help-disclosure {
24
+ flex: 0 0 auto;
25
+ }
26
+
27
+ .sounds-help-toggle {
28
+ margin-inline-start: calc(var(--design-unit) * 1px);
29
+ }
30
+
31
+ .sounds-help-toggle::before {
32
+ content: '+';
33
+ }
34
+
35
+ .sounds-help-disclosure[expanded] .sounds-help-toggle::before {
36
+ content: '−';
37
+ }
38
+
39
+ .sounds-help {
40
+ margin: 0;
41
+ font-size: var(--type-ramp-minus-1-font-size);
42
+ color: var(--neutral-foreground-hint);
43
+ text-align: left;
44
+ }
45
+
46
+ .volume-row {
47
+ display: flex;
48
+ align-items: center;
49
+ flex: 0 0 auto;
50
+ gap: calc(var(--design-unit) * 2px);
51
+ min-width: 0;
52
+ }
53
+
54
+ .volume-icon {
55
+ flex: 0 0 auto;
56
+ }
57
+
58
+ .volume-label {
59
+ flex: 0 0 auto;
60
+ font-size: var(--type-ramp-minus-1-font-size);
61
+ color: var(--neutral-foreground-hint);
62
+ }
63
+
64
+ .volume-slider {
65
+ flex: 1 1 auto;
66
+ min-width: 0;
67
+ }
68
+
69
+ .volume-percent {
70
+ flex: 0 0 auto;
71
+ min-width: 3ch;
72
+ text-align: end;
73
+ font-size: var(--type-ramp-minus-1-font-size);
74
+ color: var(--neutral-foreground-hint);
75
+ }
76
+
77
+ .mute-all {
78
+ flex: 0 0 auto;
79
+ margin: 0;
80
+ }
81
+
82
+ .sound-rules-panel {
83
+ flex: 1 1 auto;
84
+ min-height: 0;
85
+ overflow: auto;
86
+ }
87
+
88
+ .sound-rules {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 12px;
92
+ }
93
+
94
+ .sound-rule {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 8px;
98
+ padding: 12px;
99
+ border: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
100
+ border-radius: calc(var(--control-corner-radius) * 1px);
101
+
102
+ /* Keep absolute listboxes from being clipped by the card itself. */
103
+ overflow: visible;
104
+ }
105
+
106
+ .sound-rule-header {
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: space-between;
110
+ font-weight: 700;
111
+ font-size: var(--type-ramp-minus-1-font-size);
112
+ }
113
+
114
+ .sound-rule-actions {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 2px;
118
+ }
119
+
120
+ .sound-rule-label {
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 4px;
124
+ font-size: var(--type-ramp-minus-1-font-size);
125
+ color: var(--neutral-foreground-hint);
126
+ }
127
+
128
+ .pattern-input,
129
+ .match-mode-select,
130
+ .rule-select,
131
+ .sound-file-input {
132
+ width: 100%;
133
+ }
134
+
135
+ /*
136
+ * Anchor the listbox to the select host and use position:fixed so it escapes
137
+ * .sound-rules-panel overflow:auto (and the rule card). Each select sets a
138
+ * unique --sounds-select-anchor / anchor-name via inline style.
139
+ * !important beats FAST's internal absolute top/bottom for [open] selects.
140
+ */
141
+ .match-mode-select[open]::part(listbox),
142
+ .rule-select[open]::part(listbox) {
143
+ position: fixed !important;
144
+ /* stylelint-disable-next-line property-no-unknown -- CSS anchor positioning */
145
+ position-anchor: var(--sounds-select-anchor);
146
+ inset: auto !important;
147
+ top: anchor(bottom) !important;
148
+ left: anchor(left) !important;
149
+ right: anchor(right) !important;
150
+ bottom: auto !important;
151
+ width: auto !important;
152
+ margin-top: calc(var(--design-unit) * 1px);
153
+ z-index: 10000;
154
+ max-height: min(240px, 40vh);
155
+ overflow-x: hidden;
156
+ overflow-y: auto;
157
+ box-shadow: 0 4px 16px rgb(0 0 0 / 24%);
158
+ }
159
+
160
+ .match-mode-select[open][position='above']::part(listbox),
161
+ .rule-select[open][position='above']::part(listbox) {
162
+ top: auto !important;
163
+ bottom: anchor(top) !important;
164
+ margin-top: 0;
165
+ margin-bottom: calc(var(--design-unit) * 1px);
166
+ }
167
+
168
+ .sound-file-row {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 4px;
172
+ }
173
+
174
+ .sound-file-row .sound-file-input {
175
+ flex: 1;
176
+ min-width: 0;
177
+ }
178
+
179
+ .choose-sound {
180
+ cursor: pointer;
181
+ }
182
+
183
+ .sound-rules-footer {
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: space-between;
187
+ flex: 0 0 auto;
188
+ gap: 8px;
189
+ }
190
+
191
+ .rule-count {
192
+ font-size: var(--type-ramp-minus-1-font-size);
193
+ color: var(--neutral-foreground-hint);
194
+ }
195
+
196
+ .file-input {
197
+ display: none;
198
+ }
199
+
200
+ ${scrollbarStyles}
201
+ `;
@@ -0,0 +1,188 @@
1
+ import { html, ref, repeat } from '@genesislcap/web-core';
2
+ import type { AlertSoundRuleView } from '../../utils/alert-sound';
3
+ import type { FoundationSoundsPanel } from './foundation-sounds-panel';
4
+
5
+ export const FoundationSoundsPanelTemplate = html<FoundationSoundsPanel>`
6
+ <div class="sounds-controls" part="controls">
7
+ <input
8
+ ${ref('fileInput')}
9
+ class="file-input"
10
+ type="file"
11
+ accept="audio/*"
12
+ @change=${(x, c) => x.onFileSelected(c.event)}
13
+ />
14
+ <rapid-disclosure class="sounds-help-disclosure" appearance="lightweight">
15
+ <span slot="title">
16
+ Sound Alerts Information
17
+ <span class="sounds-help-toggle" aria-hidden="true"></span>
18
+ </span>
19
+ <p class="sounds-help">
20
+ Add up to ${(x) => x.maxRules} sounds.
21
+ <br />
22
+ Match text in MESSAGE or HEADER (a word or phrase), or a notification rule (including rules
23
+ created when you subscribe to a template).
24
+ <br />
25
+ The first matching rule is played.
26
+ </p>
27
+ </rapid-disclosure>
28
+ <div class="volume-row">
29
+ <rapid-icon
30
+ class="volume-icon"
31
+ name=${(x) => (x.muted || x.volumePercent === 0 ? 'volume-xmark' : 'volume-high')}
32
+ ></rapid-icon>
33
+ <span class="volume-label">Volume</span>
34
+ <rapid-slider
35
+ class="volume-slider"
36
+ min="0"
37
+ max="100"
38
+ step="1"
39
+ :value=${(x) => String(x.volumePercent)}
40
+ @change=${(x, c) => x.onVolumeChange(c.event)}
41
+ ></rapid-slider>
42
+ <span class="volume-percent">${(x) => `${x.volumePercent}%`}</span>
43
+ <rapid-button
44
+ class="mute-all"
45
+ appearance="lightweight"
46
+ title=${(x) => (x.muted ? 'Unmute alert sounds' : 'Mute all alert sounds')}
47
+ @click=${(x) => x.toggleMute()}
48
+ >
49
+ ${(x) => (x.muted ? 'Unmute' : 'Mute all')}
50
+ </rapid-button>
51
+ </div>
52
+ <div class="sound-rules-panel">
53
+ <div class="sound-rules">
54
+ ${repeat(
55
+ (x) => x.rules,
56
+ html<AlertSoundRuleView, FoundationSoundsPanel>`
57
+ <div class="sound-rule" data-rule-index=${(x) => x.index}>
58
+ <div class="sound-rule-header">
59
+ <span>Rule ${(x) => x.index + 1}</span>
60
+ <div class="sound-rule-actions">
61
+ <rapid-button
62
+ class="mute-rule"
63
+ appearance="stealth"
64
+ title=${(x) => (x.muted ? 'Unmute this rule' : 'Mute this rule')}
65
+ @click=${(x, c) => c.parent.toggleRuleMute(x.index)}
66
+ >
67
+ <rapid-icon
68
+ name=${(x) => (x.muted ? 'volume-xmark' : 'volume-high')}
69
+ ></rapid-icon>
70
+ </rapid-button>
71
+ <rapid-button
72
+ class="remove-rule"
73
+ appearance="stealth"
74
+ title=${(_, c) => (c.parent.canRemoveRule ? 'Remove rule' : 'Reset rule')}
75
+ @click=${(x, c) => c.parent.removeRule(x.index)}
76
+ >
77
+ <rapid-icon name="trash"></rapid-icon>
78
+ </rapid-button>
79
+ </div>
80
+ </div>
81
+ <label class="sound-rule-label">
82
+ Match by
83
+ <rapid-select
84
+ class="match-mode-select"
85
+ style=${(x) =>
86
+ `anchor-name: --sound-match-${x.index}; --sounds-select-anchor: --sound-match-${x.index}`}
87
+ :value=${(x) => x.matchMode}
88
+ @change=${(x, c) => c.parent.onMatchModeChange(x.index, c.event)}
89
+ >
90
+ <rapid-option value="message">MESSAGE text</rapid-option>
91
+ <rapid-option value="header">HEADER text</rapid-option>
92
+ <rapid-option value="rule">Notification rule</rapid-option>
93
+ </rapid-select>
94
+ </label>
95
+ <label
96
+ class="sound-rule-label"
97
+ style=${(x) => (x.matchMode === 'rule' ? 'display: none;' : '')}
98
+ >
99
+ ${(x) => (x.matchMode === 'header' ? 'HEADER text' : 'MESSAGE text')}
100
+ <rapid-text-field
101
+ class="pattern-input"
102
+ placeholder=${(x) =>
103
+ x.matchMode === 'header' ? 'e.g. Price alert' : 'e.g. limit breached'}
104
+ :value=${(x) => x.pattern}
105
+ @input=${(x, c) => c.parent.onPatternInput(x.index, c.event)}
106
+ ></rapid-text-field>
107
+ </label>
108
+ <label
109
+ class="sound-rule-label"
110
+ style=${(x) => (x.matchMode === 'rule' ? '' : 'display: none;')}
111
+ >
112
+ Notification rule
113
+ <rapid-select
114
+ class="rule-select"
115
+ style=${(x) =>
116
+ `anchor-name: --sound-rule-${x.index}; --sounds-select-anchor: --sound-rule-${x.index}`}
117
+ :value=${(x) => x.templateId ?? ''}
118
+ @change=${(x, c) => c.parent.onRuleChange(x.index, c.event)}
119
+ >
120
+ <options-datasource
121
+ value-field="id"
122
+ label-field="name"
123
+ option-element="rapid-option"
124
+ :data=${(_, c) => c.parent.ruleSelectOptions}
125
+ ></options-datasource>
126
+ </rapid-select>
127
+ </label>
128
+ <label class="sound-rule-label">
129
+ Sound file
130
+ <div class="sound-file-row">
131
+ <rapid-text-field
132
+ class="sound-file-input"
133
+ readonly
134
+ placeholder="No sound selected"
135
+ :value=${(x) => x.fileName ?? ''}
136
+ title=${(x) => x.fileName ?? 'No sound selected'}
137
+ >
138
+ <rapid-icon
139
+ slot="end"
140
+ class="choose-sound"
141
+ name="folder-open"
142
+ title="Choose alert sound"
143
+ @click=${(x, c) => {
144
+ c.event.stopPropagation();
145
+ return c.parent.openFilePicker(x.index);
146
+ }}
147
+ ></rapid-icon>
148
+ </rapid-text-field>
149
+ <rapid-button
150
+ class="test-sound"
151
+ appearance="stealth"
152
+ title="Test play this sound"
153
+ ?disabled=${(x) => !x.hasSound}
154
+ @click=${(x, c) => c.parent.testSound(x.index)}
155
+ >
156
+ <rapid-icon name="play"></rapid-icon>
157
+ </rapid-button>
158
+ <rapid-button
159
+ class="clear-sound"
160
+ appearance="stealth"
161
+ title="Clear sound file"
162
+ ?disabled=${(x) => !x.hasSound}
163
+ @click=${(x, c) => c.parent.clearSound(x.index)}
164
+ >
165
+ <rapid-icon name="times"></rapid-icon>
166
+ </rapid-button>
167
+ </div>
168
+ </label>
169
+ </div>
170
+ `,
171
+ )}
172
+ </div>
173
+ </div>
174
+ <div class="sound-rules-footer">
175
+ <rapid-button
176
+ class="add-rule"
177
+ appearance="lightweight"
178
+ title="Add sound rule"
179
+ ?disabled=${(x) => !x.canAddRule}
180
+ @click=${(x) => x.addRule()}
181
+ >
182
+ <rapid-icon name="plus"></rapid-icon>
183
+ Add rule
184
+ </rapid-button>
185
+ <span class="rule-count">${(x) => `${x.rules.length} / ${x.maxRules}`}</span>
186
+ </div>
187
+ </div>
188
+ `;
@@ -0,0 +1,280 @@
1
+ import { customElement, GenesisElement, observable } from '@genesislcap/web-core';
2
+ import { RuleService } from '../../services/rule.service';
3
+ import {
4
+ AlertSoundMatchMode,
5
+ AlertSoundPlayer,
6
+ AlertSoundRuleView,
7
+ AlertSoundSelectableRule,
8
+ MAX_SOUND_RULES,
9
+ } from '../../utils/alert-sound';
10
+ import { FoundationSoundsPanelStyles } from './foundation-sounds-panel.styles';
11
+ import { FoundationSoundsPanelTemplate } from './foundation-sounds-panel.template';
12
+
13
+ type NamedRuleRow = {
14
+ NAME?: string;
15
+ name?: string;
16
+ RULE_NAME?: string;
17
+ ID?: string;
18
+ id?: string;
19
+ DYNAMIC_RULE_ID?: string;
20
+ };
21
+
22
+ /**
23
+ * Configures alert sound rules in the inbox Sounds tab. Persists via the shared
24
+ * AlertSoundPlayer; playback is handled by layout-mounted
25
+ * {@link FoundationSounds}.
26
+ *
27
+ * @public
28
+ */
29
+ @customElement({
30
+ name: 'foundation-sounds-panel',
31
+ template: FoundationSoundsPanelTemplate,
32
+ styles: FoundationSoundsPanelStyles,
33
+ })
34
+ export class FoundationSoundsPanel extends GenesisElement {
35
+ @RuleService ruleService: RuleService;
36
+
37
+ /** @internal */
38
+ @observable rules: AlertSoundRuleView[] = [];
39
+ /** @internal */
40
+ @observable canAddRule = true;
41
+ /** @internal */
42
+ @observable canRemoveRule = false;
43
+ /** Global playback volume 0–100. @internal */
44
+ @observable volumePercent = 100;
45
+ /** @internal */
46
+ @observable muted = false;
47
+ /** Notification rules from REQ_NOTIFICATION_RULE. @internal */
48
+ @observable selectableRules: AlertSoundSelectableRule[] = [];
49
+ /** @internal */
50
+ @observable ruleSelectOptions: Array<{ id: string; name: string }> = [
51
+ { id: '', name: 'Select a rule...' },
52
+ ];
53
+ /** @internal */
54
+ readonly maxRules = MAX_SOUND_RULES;
55
+
56
+ fileInput: HTMLInputElement;
57
+ private pendingRuleIndex: number | null = null;
58
+ private readonly player = AlertSoundPlayer.shared;
59
+ private unsubscribePlayer: (() => void) | undefined;
60
+ /** Suppresses select change events while options are rebuilt. */
61
+ private ignoreSelectChanges = false;
62
+
63
+ async connectedCallback() {
64
+ super.connectedCallback();
65
+
66
+ await this.player.restore();
67
+
68
+ // The await above can outlive this element (e.g. the inbox flyout closing
69
+ // before restore() settles). Without this guard, disconnectedCallback runs
70
+ // first with unsubscribePlayer still unset, so it clears nothing — and we
71
+ // then add a listener to the shared AlertSoundPlayer.shared that nothing
72
+ // ever removes, plus fire a redundant loadRules() request.
73
+ if (!this.isConnected) {
74
+ return;
75
+ }
76
+
77
+ this.syncSoundState();
78
+ this.unsubscribePlayer = this.player.subscribe(() => this.syncSoundState());
79
+ await this.loadRules();
80
+ }
81
+
82
+ disconnectedCallback() {
83
+ super.disconnectedCallback();
84
+ this.unsubscribePlayer?.();
85
+ this.unsubscribePlayer = undefined;
86
+ }
87
+
88
+ onMatchModeChange(index: number, event: Event): boolean {
89
+ const value = (event.target as HTMLSelectElement).value;
90
+ const matchMode: AlertSoundMatchMode =
91
+ value === 'rule' ? 'rule' : value === 'header' ? 'header' : 'message';
92
+ if (matchMode === 'rule') {
93
+ void this.loadRules();
94
+ }
95
+ this.player.setMatchMode(index, matchMode);
96
+ // FAST preventDefaults when a binding returns void — always allow the event.
97
+ return true;
98
+ }
99
+
100
+ onPatternInput(index: number, event: Event): boolean {
101
+ const value = (event.target as HTMLInputElement).value;
102
+ this.player.setPattern(index, value);
103
+ return true;
104
+ }
105
+
106
+ onRuleChange(index: number, event: Event): boolean {
107
+ if (this.ignoreSelectChanges) {
108
+ return true;
109
+ }
110
+ const ruleId = (event.target as HTMLSelectElement).value;
111
+ if (!ruleId) {
112
+ this.player.setRule(index, null);
113
+ return true;
114
+ }
115
+ const rule = this.selectableRules.find((item) => item.id === ruleId);
116
+ if (!rule) {
117
+ return true;
118
+ }
119
+ this.player.setRule(index, rule);
120
+ return true;
121
+ }
122
+
123
+ openFilePicker(index: number): boolean {
124
+ this.pendingRuleIndex = index;
125
+ this.fileInput?.click();
126
+ return true;
127
+ }
128
+
129
+ onFileSelected(event: Event): boolean {
130
+ const input = event.target as HTMLInputElement;
131
+ const file = input.files?.[0];
132
+ const index = this.pendingRuleIndex;
133
+ this.pendingRuleIndex = null;
134
+ input.value = '';
135
+ if (!file || index == null) {
136
+ return true;
137
+ }
138
+ this.player.setSoundFromFile(index, file);
139
+ return true;
140
+ }
141
+
142
+ clearSound(index: number): boolean {
143
+ this.player.clearSound(index);
144
+ return true;
145
+ }
146
+
147
+ testSound(index: number): boolean {
148
+ this.player.playRule(index);
149
+ return true;
150
+ }
151
+
152
+ toggleRuleMute(index: number): boolean {
153
+ this.player.toggleRuleMute(index);
154
+ return true;
155
+ }
156
+
157
+ onVolumeChange(event: Event): boolean {
158
+ const value = Number((event.target as HTMLInputElement).value);
159
+ if (!Number.isFinite(value)) {
160
+ return true;
161
+ }
162
+ this.player.setVolumePercent(value);
163
+ return true;
164
+ }
165
+
166
+ toggleMute(): boolean {
167
+ this.player.toggleMute();
168
+ return true;
169
+ }
170
+
171
+ addRule(): boolean {
172
+ this.player.addRule();
173
+ return true;
174
+ }
175
+
176
+ removeRule(index: number): boolean {
177
+ this.player.removeRule(index);
178
+ return true;
179
+ }
180
+
181
+ /** Reloads the notification-rule picker (e.g. when the Sounds tab is opened). */
182
+ async reloadRules(): Promise<void> {
183
+ await this.loadRules();
184
+ }
185
+
186
+ private async loadRules(): Promise<void> {
187
+ try {
188
+ const rules = await this.ruleService.getRules();
189
+ const ruleList = (Array.isArray(rules) ? rules : []) as NamedRuleRow[];
190
+
191
+ this.selectableRules = this.mapRules(ruleList);
192
+ this.ruleSelectOptions = [
193
+ {
194
+ id: '',
195
+ name: this.selectableRules.length ? 'Select a rule...' : 'No rules available',
196
+ },
197
+ ...this.selectableRules.map((rule) => ({ id: rule.id, name: rule.name })),
198
+ ];
199
+
200
+ await this.settleRuleOptions();
201
+ this.syncSoundState();
202
+ // options-datasource clearData() wipes select.value whenever :data is
203
+ // reassigned, and :value won't re-apply if templateId didn't change — so
204
+ // push persisted rule ids back onto the selects after the rebuild.
205
+ this.applyRuleSelectValues();
206
+ } catch {
207
+ this.selectableRules = [];
208
+ this.ruleSelectOptions = [{ id: '', name: 'No rules available' }];
209
+ // Without this, a failed REQ_NOTIFICATION_RULE leaves a restored
210
+ // matchMode: 'rule' row showing the "Select a rule..." placeholder for
211
+ // good, even though the player still holds its templateId and plays fine.
212
+ this.syncSoundState();
213
+ this.applyRuleSelectValues();
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Suppresses select `change` events only while options-datasource rebuilds
219
+ * the DOM — not for the `getRules()` round trip itself, so a user picking a
220
+ * rule while a request is in flight isn't silently dropped.
221
+ */
222
+ private async settleRuleOptions(): Promise<void> {
223
+ this.ignoreSelectChanges = true;
224
+ try {
225
+ await new Promise<void>((resolve) => {
226
+ requestAnimationFrame(() => {
227
+ resolve();
228
+ });
229
+ });
230
+ } finally {
231
+ this.ignoreSelectChanges = false;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Re-applies persisted template ids onto `.rule-select` hosts after
237
+ * options-datasource has rebuilt its options. We deliberately do *not* bind
238
+ * `:initialSelectedValue` to templateId: that path compares the option label
239
+ * against Select.value (the option id), always mismatches, clearData()'s the
240
+ * select, and leaves the next pick stuck on "Select a rule...".
241
+ */
242
+ private applyRuleSelectValues(): void {
243
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.sound-rule').forEach((row) => {
244
+ const index = Number(row.getAttribute('data-rule-index'));
245
+ const rule = this.rules.find((item) => item.index === index);
246
+ const select = row.querySelector('.rule-select') as HTMLSelectElement | null;
247
+ if (!select || !rule || rule.matchMode !== 'rule') {
248
+ return;
249
+ }
250
+ select.value = rule.templateId ?? '';
251
+ });
252
+ }
253
+
254
+ private mapRules(ruleList: NamedRuleRow[]): AlertSoundSelectableRule[] {
255
+ const seenIds = new Set<string>();
256
+ return ruleList
257
+ .map((rule) => {
258
+ const name = rule?.NAME ?? rule?.RULE_NAME ?? rule?.name ?? '';
259
+ const id = this.ruleMatchId(rule);
260
+ if (!id || !name || seenIds.has(id)) {
261
+ return null;
262
+ }
263
+ seenIds.add(id);
264
+ return { id, name };
265
+ })
266
+ .filter((rule): rule is AlertSoundSelectableRule => Boolean(rule));
267
+ }
268
+
269
+ private ruleMatchId(rule: NamedRuleRow | undefined): string {
270
+ return String(rule?.DYNAMIC_RULE_ID ?? rule?.ID ?? rule?.id ?? '').trim();
271
+ }
272
+
273
+ private syncSoundState() {
274
+ this.rules = this.player.rules;
275
+ this.canAddRule = this.player.canAddRule;
276
+ this.canRemoveRule = this.player.canRemoveRule;
277
+ this.volumePercent = this.player.volumePercent;
278
+ this.muted = this.player.isMuted;
279
+ }
280
+ }
@@ -0,0 +1,8 @@
1
+ import { css } from '@genesislcap/web-core';
2
+
3
+ /** Headless layout listener — take no space in the layout chrome. */
4
+ export const FoundationSoundsStyles = css`
5
+ :host {
6
+ display: none;
7
+ }
8
+ `;