chuijs 4.0.3 → 4.0.4
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.
- package/CHANGELOG.md +536 -0
- package/framework/appLayout/default/chui_app_layout.js +361 -303
- package/framework/appLayout/default/global_style.css +243 -89
- package/framework/appLayout/default/main_theme_style.css +188 -101
- package/framework/components/chui_accordion/accordion.js +17 -17
- package/framework/components/chui_accordion/styles.css +7 -7
- package/framework/components/chui_action_sheet/action_sheet.js +35 -32
- package/framework/components/chui_action_sheet/styles.css +10 -4
- package/framework/components/chui_actions_bar/actions_bar.js +338 -0
- package/framework/components/chui_actions_bar/styles.css +139 -0
- package/framework/components/chui_affix/affix.js +4 -4
- package/framework/components/chui_affix/styles.css +5 -0
- package/framework/components/chui_alert/alert.js +11 -5
- package/framework/components/chui_alert/styles.css +7 -0
- package/framework/components/chui_avatar/avatar.js +13 -13
- package/framework/components/chui_avatar/styles.css +1 -1
- package/framework/components/chui_avatar_group/avatar_group.js +4 -4
- package/framework/components/chui_avatar_group/styles.css +1 -1
- package/framework/components/chui_backtop/backtop.js +25 -2
- package/framework/components/chui_backtop/styles.css +21 -0
- package/framework/components/chui_badge/badge.js +15 -15
- package/framework/components/chui_badge/styles.css +6 -6
- package/framework/components/chui_banner/banner.js +222 -0
- package/framework/components/chui_banner/styles.css +170 -0
- package/framework/components/chui_breadcrumb/styles.css +1 -1
- package/framework/components/chui_button/button.js +24 -28
- package/framework/components/chui_button/styles.css +5 -5
- package/framework/components/chui_calendar/calendar.js +6 -6
- package/framework/components/chui_card/card.js +14 -14
- package/framework/components/chui_card_scroll/card_scroll.js +169 -0
- package/framework/components/chui_card_scroll/styles.css +86 -0
- package/framework/components/chui_chat/chat.js +73 -51
- package/framework/components/chui_chat/styles.css +18 -7
- package/framework/components/chui_clipboard_history/clipboard_history.js +25 -20
- package/framework/components/chui_clipboard_history/styles.css +10 -8
- package/framework/components/chui_code/code.js +8 -8
- package/framework/components/chui_code/styles.css +2 -2
- package/framework/components/chui_code_editor/code_editor.js +26 -26
- package/framework/components/chui_code_editor/styles.css +3 -3
- package/framework/components/chui_command_palette/command_palette.js +29 -29
- package/framework/components/chui_command_palette/styles.css +5 -5
- package/framework/components/chui_console/console.js +9 -9
- package/framework/components/chui_console/styles.css +5 -2
- package/framework/components/chui_content_block/content_block.js +2 -2
- package/framework/components/chui_context_menu/context_menu.js +162 -54
- package/framework/components/chui_context_menu/styles.css +82 -8
- package/framework/components/chui_custom_element/custom_element.js +11 -11
- package/framework/components/chui_data_grid/data_grid.js +268 -268
- package/framework/components/chui_data_grid/styles.css +14 -14
- package/framework/components/chui_descriptions/styles.css +1 -1
- package/framework/components/chui_details/details.js +12 -12
- package/framework/components/chui_details/styles.css +7 -7
- package/framework/components/chui_diff_viewer/diff_viewer.js +88 -88
- package/framework/components/chui_diff_viewer/styles.css +9 -9
- package/framework/components/chui_divider/divider.js +6 -6
- package/framework/components/chui_divider/styles.css +2 -2
- package/framework/components/chui_drawer/drawer.js +37 -14
- package/framework/components/chui_drawer/styles.css +18 -4
- package/framework/components/chui_drop_zone/drop_zone.js +6 -6
- package/framework/components/chui_drop_zone/styles.css +10 -6
- package/framework/components/chui_fieldset/fieldset.js +6 -6
- package/framework/components/chui_fieldset/styles.css +5 -5
- package/framework/components/chui_file_explorer/file_explorer.js +115 -115
- package/framework/components/chui_file_explorer/styles.css +6 -6
- package/framework/components/chui_form/form.js +11 -11
- package/framework/components/chui_gantt/gantt.js +41 -41
- package/framework/components/chui_gantt/styles.css +5 -5
- package/framework/components/chui_graphs/graphs.js +82 -46
- package/framework/components/chui_graphs/styles.css +26 -5
- package/framework/components/chui_hero/hero.js +63 -20
- package/framework/components/chui_hero/styles.css +176 -7
- package/framework/components/chui_html_block/html_block.js +5 -5
- package/framework/components/chui_html_block/styles.css +1 -1
- package/framework/components/chui_hx/styles.css +2 -2
- package/framework/components/chui_icons/icons.js +7 -7
- package/framework/components/chui_icons/styles.css +2 -1
- package/framework/components/chui_inputs/chui_check_box/styles.css +12 -3
- package/framework/components/chui_inputs/chui_chips/chips.js +92 -31
- package/framework/components/chui_inputs/chui_chips/styles.css +28 -5
- package/framework/components/chui_inputs/chui_color_picker/color_picker.js +20 -15
- package/framework/components/chui_inputs/chui_color_picker/styles.css +7 -7
- package/framework/components/chui_inputs/chui_combo_box/combo_box.js +58 -58
- package/framework/components/chui_inputs/chui_combo_box/styles.css +9 -9
- package/framework/components/chui_inputs/chui_date/date.js +93 -95
- package/framework/components/chui_inputs/chui_date/styles.css +8 -8
- package/framework/components/chui_inputs/chui_date_range/date_range.js +22 -22
- package/framework/components/chui_inputs/chui_date_range/styles.css +3 -3
- package/framework/components/chui_inputs/chui_datetime/datetime.js +12 -12
- package/framework/components/chui_inputs/chui_datetime/styles.css +3 -3
- package/framework/components/chui_inputs/chui_email/email.js +10 -10
- package/framework/components/chui_inputs/chui_email/styles.css +6 -6
- package/framework/components/chui_inputs/chui_file/file.js +8 -8
- package/framework/components/chui_inputs/chui_file/styles.css +12 -9
- package/framework/components/chui_inputs/chui_hotkey/hotkey.js +39 -39
- package/framework/components/chui_inputs/chui_hotkey/styles.css +6 -6
- package/framework/components/chui_inputs/chui_masked/masked.js +44 -44
- package/framework/components/chui_inputs/chui_masked/styles.css +5 -5
- package/framework/components/chui_inputs/chui_mention/mention.js +13 -13
- package/framework/components/chui_inputs/chui_mention/styles.css +14 -8
- package/framework/components/chui_inputs/chui_multi_combo_box/multi_combo_box.js +147 -137
- package/framework/components/chui_inputs/chui_multi_combo_box/styles.css +14 -8
- package/framework/components/chui_inputs/chui_number/number.js +42 -45
- package/framework/components/chui_inputs/chui_number/styles.css +6 -6
- package/framework/components/chui_inputs/chui_otp/otp.js +32 -32
- package/framework/components/chui_inputs/chui_otp/styles.css +5 -5
- package/framework/components/chui_inputs/chui_password/password.js +30 -36
- package/framework/components/chui_inputs/chui_password/styles.css +7 -7
- package/framework/components/chui_inputs/chui_radio_button/styles.css +2 -2
- package/framework/components/chui_inputs/chui_rating/rating.js +28 -28
- package/framework/components/chui_inputs/chui_rating/styles.css +3 -3
- package/framework/components/chui_inputs/chui_select_box/select_box.js +54 -54
- package/framework/components/chui_inputs/chui_select_box/styles.css +10 -10
- package/framework/components/chui_inputs/chui_slider/slider.js +14 -14
- package/framework/components/chui_inputs/chui_slider/styles.css +5 -4
- package/framework/components/chui_inputs/chui_text/styles.css +7 -7
- package/framework/components/chui_inputs/chui_text/text.js +13 -17
- package/framework/components/chui_inputs/chui_text_area/styles.css +8 -8
- package/framework/components/chui_inputs/chui_text_area/text_area.js +23 -23
- package/framework/components/chui_inputs/chui_time/styles.css +13 -17
- package/framework/components/chui_inputs/chui_time/time.js +35 -24
- package/framework/components/chui_inputs/chui_toggle/styles.css +10 -3
- package/framework/components/chui_inputs/chui_toggle/toggle.js +16 -16
- package/framework/components/chui_json_editor/json_editor.js +87 -87
- package/framework/components/chui_json_editor/styles.css +5 -5
- package/framework/components/chui_json_viewer/json_viewer.js +80 -80
- package/framework/components/chui_json_viewer/styles.css +6 -6
- package/framework/components/chui_kanban/kanban.js +27 -27
- package/framework/components/chui_kanban/styles.css +8 -4
- package/framework/components/chui_kbd/styles.css +1 -1
- package/framework/components/chui_label/label.js +17 -17
- package/framework/components/chui_label/styles.css +1 -1
- package/framework/components/chui_link/link.js +9 -9
- package/framework/components/chui_link/styles.css +11 -4
- package/framework/components/chui_list/list.js +11 -11
- package/framework/components/chui_list/styles.css +2 -2
- package/framework/components/chui_markdown_editor/markdown_editor.js +66 -66
- package/framework/components/chui_markdown_editor/styles.css +5 -5
- package/framework/components/chui_media/audio.js +240 -246
- package/framework/components/chui_media/audio_styles.css +41 -23
- package/framework/components/chui_media/image.js +49 -46
- package/framework/components/chui_media/image_compare.js +35 -35
- package/framework/components/chui_media/image_compare_styles.css +5 -5
- package/framework/components/chui_media/image_styles.css +1 -1
- package/framework/components/chui_media/video.js +148 -148
- package/framework/components/chui_media/video_styles.css +27 -11
- package/framework/components/chui_menu_bar/menu_bar.js +54 -54
- package/framework/components/chui_menu_bar/styles.css +10 -10
- package/framework/components/chui_message_bar/message_bar.js +551 -0
- package/framework/components/chui_message_bar/styles.css +248 -0
- package/framework/components/chui_modal/modal.js +37 -13
- package/framework/components/chui_notification/notification.js +76 -54
- package/framework/components/chui_notification/notification_download.js +26 -81
- package/framework/components/chui_notification/notification_download_card.js +115 -0
- package/framework/components/chui_notification/notification_download_progress.css +2 -2
- package/framework/components/chui_notification/notification_download_progress.js +70 -108
- package/framework/components/chui_notification/notification_update.js +31 -31
- package/framework/components/chui_notification/status_plate.css +3 -3
- package/framework/components/chui_notification/styles.css +6 -1
- package/framework/components/chui_notification/styles_download.css +1 -1
- package/framework/components/chui_notification/styles_update.css +2 -2
- package/framework/components/chui_org_chart/org_chart.js +29 -29
- package/framework/components/chui_org_chart/styles.css +2 -2
- package/framework/components/chui_pagination/pagination.js +3 -3
- package/framework/components/chui_pagination/styles.css +1 -1
- package/framework/components/chui_paragraph/styles.css +2 -2
- package/framework/components/chui_popover/popover.js +29 -23
- package/framework/components/chui_popover/styles.css +1 -1
- package/framework/components/chui_popups/popups.js +124 -124
- package/framework/components/chui_popups/styles.css +5 -5
- package/framework/components/chui_print_button/print_button.js +15 -10
- package/framework/components/chui_progress_bar/progress_bar.js +34 -12
- package/framework/components/chui_progress_bar/styles.css +4 -4
- package/framework/components/chui_progress_ring/progress_ring.js +15 -15
- package/framework/components/chui_progress_ring/styles.css +1 -1
- package/framework/components/chui_qrcode/qrcode.js +12 -12
- package/framework/components/chui_query_builder/query_builder.js +46 -46
- package/framework/components/chui_query_builder/styles.css +4 -4
- package/framework/components/chui_radio_group/styles.css +1 -1
- package/framework/components/chui_result/result.js +15 -15
- package/framework/components/chui_result/styles.css +3 -3
- package/framework/components/chui_scheduler/scheduler.js +74 -74
- package/framework/components/chui_scheduler/styles.css +5 -5
- package/framework/components/chui_screen_spinner/screen_spinner.js +167 -0
- package/framework/components/chui_screen_spinner/styles.css +49 -0
- package/framework/components/chui_segmented_control/segmented_control.js +63 -6
- package/framework/components/chui_segmented_control/styles.css +32 -6
- package/framework/components/chui_settings/settings.js +45 -46
- package/framework/components/chui_settings/styles.css +4 -4
- package/framework/components/chui_shortcut_help/shortcut_help.js +32 -34
- package/framework/components/chui_sidebar_nav/sidebar_nav.js +98 -29
- package/framework/components/chui_sidebar_nav/styles.css +9 -3
- package/framework/components/chui_slideshow/slideshow.js +85 -85
- package/framework/components/chui_slideshow/styles.css +6 -6
- package/framework/components/chui_spinner/spinner.js +10 -10
- package/framework/components/chui_split_button/split_button.js +30 -12
- package/framework/components/chui_split_button/styles.css +21 -5
- package/framework/components/chui_splitter/splitter.js +11 -11
- package/framework/components/chui_splitter/styles.css +2 -2
- package/framework/components/chui_statistic/statistic.js +47 -47
- package/framework/components/chui_status_bar/styles.css +2 -2
- package/framework/components/chui_stepper/stepper.js +202 -31
- package/framework/components/chui_stepper/styles.css +79 -14
- package/framework/components/chui_sticky_bar/sticky_bar.js +44 -13
- package/framework/components/chui_sticky_bar/styles.css +10 -8
- package/framework/components/chui_tabs/chui_tabs.js +74 -26
- package/framework/components/chui_tabs/styles.css +40 -12
- package/framework/components/chui_text_editor/styles.css +7 -7
- package/framework/components/chui_text_editor/text_editor.js +56 -56
- package/framework/components/chui_text_editor/text_editor_panel.js +173 -174
- package/framework/components/chui_theme_toggle/theme_toggle.js +21 -5
- package/framework/components/chui_timeline/styles.css +3 -1
- package/framework/components/chui_toast_queue/styles.css +11 -11
- package/framework/components/chui_toast_queue/toast_queue.js +30 -27
- package/framework/components/chui_toolbar/styles.css +2 -2
- package/framework/components/chui_tooltip/styles.css +2 -2
- package/framework/components/chui_tooltip/tooltip.js +3 -3
- package/framework/components/chui_tour/styles.css +8 -8
- package/framework/components/chui_tour/tour.js +72 -72
- package/framework/components/chui_transfer/styles.css +6 -6
- package/framework/components/chui_transfer/transfer.js +93 -106
- package/framework/components/chui_tree_table/styles.css +4 -4
- package/framework/components/chui_tree_table/tree_table.js +45 -45
- package/framework/components/chui_tree_view/styles.css +10 -8
- package/framework/components/chui_tree_view/tree_view.js +185 -183
- package/framework/components/chui_user_bar/styles.css +145 -0
- package/framework/components/chui_user_bar/user_bar.js +402 -0
- package/framework/components/chui_user_list_bar/styles.css +173 -0
- package/framework/components/chui_user_list_bar/user_list_bar.js +485 -0
- package/framework/components/chui_watermark/watermark.js +17 -17
- package/framework/components/chui_webview/webview.js +9 -9
- package/framework/components/chui_write_bar/styles.css +172 -0
- package/framework/components/chui_write_bar/write_bar.js +320 -0
- package/framework/main_window.js +1234 -0
- package/framework/modules/chui_a11y/chui_a11y.js +78 -79
- package/framework/modules/chui_animations/animations.js +77 -15
- package/framework/modules/chui_animations/motion.js +120 -0
- package/framework/modules/chui_animations/styles.css +36 -3
- package/framework/modules/chui_functions.js +31 -27
- package/framework/modules/chui_highlight/styles.css +13 -10
- package/framework/modules/chui_i18n/chui_i18n.js +3 -3
- package/framework/modules/chui_i18n/locales/feedback.js +62 -0
- package/framework/modules/chui_i18n/locales/navigation.js +4 -0
- package/framework/modules/chui_json/chui_json.js +2 -2
- package/framework/modules/chui_logger/chui_logger.js +23 -8
- package/framework/modules/chui_page/page.js +10 -10
- package/framework/modules/chui_palette/palette.js +61 -0
- package/framework/modules/chui_qr/chui_qr.js +23 -23
- package/framework/modules/chui_scrollbar/chui_scrollbar.js +45 -45
- package/framework/modules/chui_scrollbar/styles.css +3 -3
- package/framework/modules/chui_services/chui_services.js +8 -8
- package/framework/modules/chui_services/desktop.js +77 -71
- package/framework/modules/chui_sortable/chui_sortable.js +17 -17
- package/framework/modules/chui_validate/chui_validate.js +3 -3
- package/framework/tray_bar/chui_menu_items.js +2 -2
- package/index.d.ts +266 -34
- package/index.js +20 -1168
- package/package.json +6 -4
- package/framework/components/telegram_bot/chui_telegram_bot.js +0 -191
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Animation } = require("../../modules/chui_animations/animations");
|
|
2
|
+
const { reducedMotion, tokenTime } = require("../../modules/chui_animations/motion");
|
|
2
3
|
const { Icon, Icons } = require("../../components/chui_icons/icons");
|
|
3
4
|
const { Button } = require("../../components/chui_button/button");
|
|
4
5
|
// НАСТРОЙКИ
|
|
@@ -8,7 +9,7 @@ const { ContentBlock } = require("../../components/chui_content_block/content_bl
|
|
|
8
9
|
const { Popup } = require("../../components/chui_popups/popups");
|
|
9
10
|
const { UpdateNotification } = require("../../components/chui_notification/notification_update");
|
|
10
11
|
const { ipcRenderer } = require("electron");
|
|
11
|
-
const
|
|
12
|
+
const chuiFunctions = require("../../modules/chui_functions");
|
|
12
13
|
const { DownloadNotification } = require("../../components/chui_notification/notification_download");
|
|
13
14
|
const { Log } = require("../../../framework/modules/chui_logger/chui_logger");
|
|
14
15
|
const { MenuKeyboard, makeKeyboardButton } = require("../../modules/chui_a11y/chui_a11y");
|
|
@@ -22,13 +23,13 @@ const { I18n } = require("../../modules/chui_i18n/chui_i18n");
|
|
|
22
23
|
const fs = require("fs");
|
|
23
24
|
|
|
24
25
|
// Глобальные обработчики ошибок renderer-процесса вешаются один раз на процесс
|
|
25
|
-
let
|
|
26
|
+
let appErrorHandlersInstalled = false;
|
|
26
27
|
|
|
27
28
|
class Events {
|
|
28
|
-
#
|
|
29
|
+
#routeEvent = undefined;
|
|
29
30
|
constructor() {}
|
|
30
31
|
route(page) {
|
|
31
|
-
this.#
|
|
32
|
+
this.#routeEvent = new CustomEvent("route_event_" + page.constructor.name, {
|
|
32
33
|
bubbles: true,
|
|
33
34
|
detail: {
|
|
34
35
|
class: page.constructor.name,
|
|
@@ -36,7 +37,7 @@ class Events {
|
|
|
36
37
|
page: page,
|
|
37
38
|
},
|
|
38
39
|
});
|
|
39
|
-
document.dispatchEvent(this.#
|
|
40
|
+
document.dispatchEvent(this.#routeEvent);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -52,19 +53,19 @@ const USER_DROPDOWN_EDGE = 8;
|
|
|
52
53
|
* а рядом висело название страницы. Класс на шапке прячет page_name — см. global_style.css.
|
|
53
54
|
*/
|
|
54
55
|
function applyPageMenuBar(header, menuBar) {
|
|
55
|
-
const
|
|
56
|
+
const pageName = requireLayoutNode(LAYOUT_ROLE.PAGE_NAME);
|
|
56
57
|
const previous = header.querySelector("chui_menu_bar_main");
|
|
57
58
|
// Панели выпадающих списков живут в body (у шапки свой backdrop-filter), а не в полосе,
|
|
58
59
|
// поэтому снятие полосы обязано убирать и их: иначе на каждой смене страницы в body
|
|
59
60
|
// оставалась бы пачка невидимых панелей. Своя же полоса, вернувшаяся на место, не трогается.
|
|
60
61
|
if (previous !== null && previous !== menuBar) MenuBar.destroy(previous);
|
|
61
62
|
if (previous !== null) previous.remove();
|
|
62
|
-
const
|
|
63
|
-
header.classList.toggle(HEADER_WITH_MENU_BAR_CLASS,
|
|
64
|
-
if (!
|
|
63
|
+
const hasMenuBar = menuBar !== undefined && menuBar !== null;
|
|
64
|
+
header.classList.toggle(HEADER_WITH_MENU_BAR_CLASS, hasMenuBar);
|
|
65
|
+
if (!hasMenuBar) return;
|
|
65
66
|
new Animation(menuBar).fadeIn();
|
|
66
67
|
// Полоса встаёт туда же, где стояло название — сразу за кнопкой меню маршрутов
|
|
67
|
-
|
|
68
|
+
pageName.after(menuBar);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/**
|
|
@@ -75,9 +76,9 @@ function applyPageMenuBar(header, menuBar) {
|
|
|
75
76
|
* многоточием (см. `page_name` в global_style.css), и прочитать его целиком можно только так.
|
|
76
77
|
*/
|
|
77
78
|
function setPageName(title = String()) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const pageName = requireLayoutNode(LAYOUT_ROLE.PAGE_NAME);
|
|
80
|
+
pageName.innerHTML = String(title);
|
|
81
|
+
pageName.setAttribute("title", pageName.innerText);
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
class Route extends Events {
|
|
@@ -85,7 +86,7 @@ class Route extends Events {
|
|
|
85
86
|
super();
|
|
86
87
|
}
|
|
87
88
|
go(page) {
|
|
88
|
-
|
|
89
|
+
const center = requireLayoutNode(LAYOUT_ROLE.CENTER);
|
|
89
90
|
//
|
|
90
91
|
setPageName(page.getTitle());
|
|
91
92
|
center.innerHTML = "";
|
|
@@ -132,22 +133,22 @@ function makeHeaderButtonBox(options = { title: undefined, icon: undefined, reve
|
|
|
132
133
|
const button = document.createElement("header_button");
|
|
133
134
|
const title = document.createElement("header_button_title");
|
|
134
135
|
const icon = document.createElement("header_button_icon");
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
if (
|
|
139
|
-
if (
|
|
136
|
+
const hasTitle = options.title !== undefined && options.title !== null && options.title !== "";
|
|
137
|
+
const hasIcon = options.icon !== undefined && options.icon !== null;
|
|
138
|
+
if (hasTitle) title.innerText = options.title;
|
|
139
|
+
if (hasIcon) icon.innerHTML = new Icon(options.icon, "var(--header_icon_size)").getHTML();
|
|
140
|
+
if (hasTitle && hasIcon) {
|
|
140
141
|
// `reverse` — значок перед подписью; зазор 6 px в обоих случаях держит сам значок
|
|
141
142
|
icon.style[options.reverse ? "marginRight" : "marginLeft"] = "6px";
|
|
142
143
|
button.appendChild(options.reverse ? icon : title);
|
|
143
144
|
button.appendChild(options.reverse ? title : icon);
|
|
144
|
-
} else if (
|
|
145
|
+
} else if (hasTitle) {
|
|
145
146
|
button.appendChild(title);
|
|
146
|
-
} else if (
|
|
147
|
+
} else if (hasIcon) {
|
|
147
148
|
// Отступы квадратной кнопки держит CSS по признаку «подписи нет»
|
|
148
149
|
button.appendChild(icon);
|
|
149
150
|
}
|
|
150
|
-
makeHeaderButton(button,
|
|
151
|
+
makeHeaderButton(button, hasIcon ? icon : undefined, hasTitle ? undefined : options.label);
|
|
151
152
|
return button;
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -178,9 +179,9 @@ class WindowControls {
|
|
|
178
179
|
#minimize = document.createElement("wc_minimize");
|
|
179
180
|
// Подписка на нересайзящееся окно: set() вызывается и из конструктора Header, и из
|
|
180
181
|
// addWC(), поэтому в set() слушатель ipcRenderer накапливался.
|
|
181
|
-
#
|
|
182
|
+
#resizableFalseEvent = () => this.#maximize.remove();
|
|
182
183
|
// Номер запроса состояния гасит ответы, пришедшие не в том порядке
|
|
183
|
-
#
|
|
184
|
+
#syncRequest = 0;
|
|
184
185
|
constructor() {
|
|
185
186
|
// Скрипт окна может быть preload'ом и выполняться до документа: здесь документ уже настоящий,
|
|
186
187
|
// поэтому `dir`/`lang` (и зеркалирование логических свойств CSS) встают гарантированно
|
|
@@ -197,9 +198,9 @@ class WindowControls {
|
|
|
197
198
|
this.#maximize.addEventListener("click", () => this.#windowAction("toggle-maximize"));
|
|
198
199
|
// Разворот приходит не только от нашей кнопки (двойной клик по полосе, меню окна),
|
|
199
200
|
// поэтому состояние читаем по факту изменения размера окна
|
|
200
|
-
window.addEventListener("resize", this.#
|
|
201
|
+
window.addEventListener("resize", this.#syncMaximizedEvent);
|
|
201
202
|
this.#syncMaximized();
|
|
202
|
-
ipcRenderer.on("chui_resizable_false", this.#
|
|
203
|
+
ipcRenderer.on("chui_resizable_false", this.#resizableFalseEvent);
|
|
203
204
|
}
|
|
204
205
|
/** Кнопка окна — значок с именем для скринридера, фокусом и активацией с клавиатуры.
|
|
205
206
|
* Значок рисуется линиями (`WINDOW_ICONS`), размер задаёт CSS (см. global_style.css) */
|
|
@@ -236,9 +237,9 @@ class WindowControls {
|
|
|
236
237
|
// Состояние приходит асинхронно, поэтому ответ устаревшего запроса отбрасывается по номеру:
|
|
237
238
|
// иначе быстрая пара «развернуть/восстановить» могла бы применить ответ в обратном порядке
|
|
238
239
|
#syncMaximized() {
|
|
239
|
-
const request = ++this.#
|
|
240
|
+
const request = ++this.#syncRequest;
|
|
240
241
|
this.#windowState().then((state) => {
|
|
241
|
-
if (request !== this.#
|
|
242
|
+
if (request !== this.#syncRequest) return;
|
|
242
243
|
const maximized = state.isMaximized === true;
|
|
243
244
|
const label = maximized ? I18n.t("appLayout.window.restore") : I18n.t("appLayout.window.maximize");
|
|
244
245
|
this.#maximize.classList.toggle("wc_maximized", maximized);
|
|
@@ -247,9 +248,9 @@ class WindowControls {
|
|
|
247
248
|
this.#maximize.setAttribute("aria-label", label);
|
|
248
249
|
});
|
|
249
250
|
}
|
|
250
|
-
#
|
|
251
|
-
set(
|
|
252
|
-
if (
|
|
251
|
+
#syncMaximizedEvent = () => this.#syncMaximized();
|
|
252
|
+
set(isEnabled = Boolean()) {
|
|
253
|
+
if (isEnabled) {
|
|
253
254
|
this.#box.appendChild(this.#close);
|
|
254
255
|
this.#box.appendChild(this.#maximize);
|
|
255
256
|
this.#box.appendChild(this.#minimize);
|
|
@@ -263,23 +264,23 @@ class WindowControls {
|
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
class Header {
|
|
266
|
-
#
|
|
267
|
+
#wcBox = new WindowControls();
|
|
267
268
|
#header = document.createElement("header");
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
269
|
+
#headerMain = document.createElement("header_main");
|
|
270
|
+
#headerLeftBox = document.createElement("header_left_box");
|
|
271
|
+
#headerRightBox = document.createElement("header_right_box");
|
|
271
272
|
// Высота полосы измеряется, а не предполагается — см. #observeHeight
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
273
|
+
#heightObserver = undefined;
|
|
274
|
+
#heightFrame = 0;
|
|
275
|
+
#publishedHeight = 0;
|
|
275
276
|
constructor(layout) {
|
|
276
277
|
layout.set(LAYOUT_ROLE.HEADER, this.#header);
|
|
277
278
|
this.#header.id = "header";
|
|
278
|
-
this.#
|
|
279
|
-
this.#
|
|
280
|
-
this.#
|
|
281
|
-
this.#
|
|
282
|
-
this.#header.appendChild(this.#
|
|
279
|
+
this.#headerMain.id = "header_main";
|
|
280
|
+
this.#headerMain.appendChild(this.#headerLeftBox);
|
|
281
|
+
this.#headerMain.appendChild(this.#headerRightBox);
|
|
282
|
+
this.#headerMain.appendChild(this.#wcBox.set(false));
|
|
283
|
+
this.#header.appendChild(this.#headerMain);
|
|
283
284
|
this.#observeHeight();
|
|
284
285
|
}
|
|
285
286
|
/*
|
|
@@ -292,8 +293,8 @@ class Header {
|
|
|
292
293
|
* Теперь высота измеряется и публикуется, поэтому токен верен при любой полосе.
|
|
293
294
|
*/
|
|
294
295
|
#observeHeight() {
|
|
295
|
-
this.#
|
|
296
|
-
this.#
|
|
296
|
+
this.#heightObserver = new ResizeObserver(() => this.#publishHeight());
|
|
297
|
+
this.#heightObserver.observe(this.#header);
|
|
297
298
|
this.#publishHeight();
|
|
298
299
|
}
|
|
299
300
|
/*
|
|
@@ -305,42 +306,41 @@ class Header {
|
|
|
305
306
|
* записи без проверки крутят пересчёт на ровном месте.
|
|
306
307
|
*/
|
|
307
308
|
#publishHeight() {
|
|
308
|
-
if (this.#
|
|
309
|
-
this.#
|
|
310
|
-
this.#
|
|
309
|
+
if (this.#heightFrame !== 0) return;
|
|
310
|
+
this.#heightFrame = requestAnimationFrame(() => {
|
|
311
|
+
this.#heightFrame = 0;
|
|
311
312
|
// offsetHeight включает черту снизу: она принадлежит полосе, от неё и считается отступ
|
|
312
313
|
const height = this.#header.offsetHeight;
|
|
313
|
-
if (height === 0 || height === this.#
|
|
314
|
-
this.#
|
|
314
|
+
if (height === 0 || height === this.#publishedHeight) return;
|
|
315
|
+
this.#publishedHeight = height;
|
|
315
316
|
document.documentElement.style.setProperty("--header_height", `${height}px`);
|
|
316
317
|
});
|
|
317
318
|
}
|
|
318
319
|
addWC(boolean = Boolean()) {
|
|
319
|
-
this.#
|
|
320
|
+
this.#headerMain.insertBefore(this.#wcBox.set(boolean), this.#headerMain.firstChild);
|
|
320
321
|
}
|
|
321
322
|
addToLeftBeforeTitle(...components) {
|
|
322
|
-
for (
|
|
323
|
-
this.#header_left_box.insertBefore(component, this.#header_left_box.firstChild);
|
|
323
|
+
for (const component of components) this.#headerLeftBox.insertBefore(component, this.#headerLeftBox.firstChild);
|
|
324
324
|
}
|
|
325
325
|
/** Прежнее имя с опечаткой (`Tittle`): оставлено синонимом, чтобы не ломать чужие вызовы */
|
|
326
326
|
addToLeftBeforeTittle(...components) {
|
|
327
327
|
this.addToLeftBeforeTitle(...components);
|
|
328
328
|
}
|
|
329
329
|
addToLeft(...components) {
|
|
330
|
-
for (
|
|
331
|
-
this.#
|
|
330
|
+
for (const component of components) {
|
|
331
|
+
this.#headerLeftBox.appendChild(component);
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
addToRight(...components) {
|
|
335
|
-
for (
|
|
336
|
-
this.#
|
|
335
|
+
for (const component of components)
|
|
336
|
+
this.#headerRightBox.insertBefore(component, this.#headerRightBox.firstChild);
|
|
337
337
|
}
|
|
338
338
|
removeToRight(...components) {
|
|
339
|
-
for (
|
|
339
|
+
for (const component of components) {
|
|
340
340
|
// У меню профиля панель лежит в body, а не в шапке (см. UserProfile),
|
|
341
341
|
// поэтому снятие кнопки обязано убирать и панель: иначе в body копились бы невидимые меню
|
|
342
342
|
UserProfile.destroy(component);
|
|
343
|
-
this.#
|
|
343
|
+
this.#headerRightBox.removeChild(component);
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
set() {
|
|
@@ -370,23 +370,23 @@ class AppMenu {
|
|
|
370
370
|
#routeViews = document.createElement("route_views");
|
|
371
371
|
#appMenuButton = document.createElement("app_menu_button");
|
|
372
372
|
// Шапка панели: плитка со значком и заголовок — панель читается выдвижным ящиком
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
373
|
+
#appMenuHeader = document.createElement("app_menu_header");
|
|
374
|
+
#appMenuHeaderIcon = document.createElement("app_menu_header_icon");
|
|
375
|
+
#appMenuHeaderTitle = document.createElement("app_menu_header_title");
|
|
376
376
|
// Блок поиска: своя поверхность со значком, а не одинокий ввод на фоне панели
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
377
|
+
#appMenuSearchMain = document.createElement("app_menu_search_main");
|
|
378
|
+
#appMenuSearchBox = document.createElement("app_menu_search_box");
|
|
379
|
+
#appMenuSearchIcon = document.createElement("app_menu_search_icon");
|
|
380
|
+
#appMenuSearchInput = document.createElement("input");
|
|
381
381
|
// Подсказка, когда фильтр не нашёл ни одного пункта
|
|
382
|
-
#
|
|
382
|
+
#appMenuEmpty = document.createElement("app_menu_empty");
|
|
383
383
|
// ===
|
|
384
|
-
#
|
|
385
|
-
#
|
|
384
|
+
#autoClose = false;
|
|
385
|
+
#searchEnabled = false;
|
|
386
386
|
#routeKeyboard = undefined;
|
|
387
387
|
#header = undefined;
|
|
388
388
|
#center = undefined;
|
|
389
|
-
#
|
|
389
|
+
#pageName = document.createElement("page_name");
|
|
390
390
|
constructor(header, center, layout) {
|
|
391
391
|
this.#header = header;
|
|
392
392
|
this.#center = center;
|
|
@@ -415,22 +415,22 @@ class AppMenu {
|
|
|
415
415
|
if (this.#appMenu.style.transform === `translateX(${this.#appMenuSlide}px)`) this.#close();
|
|
416
416
|
}
|
|
417
417
|
});
|
|
418
|
-
this.#
|
|
419
|
-
layout.set(LAYOUT_ROLE.PAGE_NAME, this.#
|
|
420
|
-
header.addToLeft(this.#appMenuButton, this.#
|
|
421
|
-
this.#
|
|
418
|
+
this.#pageName.id = "page_name";
|
|
419
|
+
layout.set(LAYOUT_ROLE.PAGE_NAME, this.#pageName);
|
|
420
|
+
header.addToLeft(this.#appMenuButton, this.#pageName);
|
|
421
|
+
this.#appMenuHeaderIcon.appendChild(new Icon(Icons.NAVIGATION.MENU_OPEN, "16px").set());
|
|
422
422
|
// Плитка и значок в ней — украшение: имя панели уже есть в заголовке
|
|
423
|
-
this.#
|
|
424
|
-
this.#
|
|
423
|
+
this.#appMenuHeaderIcon.setAttribute("aria-hidden", "true");
|
|
424
|
+
this.#appMenuHeaderTitle.innerText = I18n.t("appLayout.menu");
|
|
425
425
|
// Список пунктов назван головкой панели: скринридер читает «Меню», а не безымянный список
|
|
426
|
-
this.#
|
|
427
|
-
this.#routeViews.setAttribute("aria-labelledby", this.#
|
|
428
|
-
this.#
|
|
429
|
-
this.#
|
|
430
|
-
this.#
|
|
426
|
+
this.#appMenuHeaderTitle.id = "chui_app_menu_title";
|
|
427
|
+
this.#routeViews.setAttribute("aria-labelledby", this.#appMenuHeaderTitle.id);
|
|
428
|
+
this.#appMenuHeader.appendChild(this.#appMenuHeaderIcon);
|
|
429
|
+
this.#appMenuHeader.appendChild(this.#appMenuHeaderTitle);
|
|
430
|
+
this.#appMenuEmpty.innerText = I18n.t("appLayout.search.empty");
|
|
431
431
|
// Подсказка лежит внутри списка, но пунктом не является: клавиатура меню её не видит (itemSelector "route")
|
|
432
|
-
this.#routeViews.appendChild(this.#
|
|
433
|
-
this.#appMenu.appendChild(this.#
|
|
432
|
+
this.#routeViews.appendChild(this.#appMenuEmpty);
|
|
433
|
+
this.#appMenu.appendChild(this.#appMenuHeader);
|
|
434
434
|
this.#appMenu.appendChild(this.#routeViews);
|
|
435
435
|
// Клавиатура и aria меню маршрутов: стрелки по пунктам, Escape закрывает и возвращает фокус на кнопку
|
|
436
436
|
this.#routeKeyboard = new MenuKeyboard(this.#routeViews, {
|
|
@@ -471,20 +471,20 @@ class AppMenu {
|
|
|
471
471
|
this.#routeKeyboard.setExpanded(false);
|
|
472
472
|
}
|
|
473
473
|
enableSearchInput() {
|
|
474
|
-
if (this.#
|
|
475
|
-
this.#
|
|
476
|
-
this.#
|
|
477
|
-
this.#
|
|
478
|
-
this.#
|
|
474
|
+
if (this.#searchEnabled) return;
|
|
475
|
+
this.#searchEnabled = true;
|
|
476
|
+
this.#appMenuSearchInput.classList.add("app_menu_search_input");
|
|
477
|
+
this.#appMenuSearchInput.placeholder = I18n.t("appLayout.search.placeholder");
|
|
478
|
+
this.#appMenuSearchInput.setAttribute("aria-label", I18n.t("appLayout.search.sections"));
|
|
479
479
|
// Подсветку поля даёт :focus-within на поверхности поиска — инлайновых стилей больше нет
|
|
480
|
-
this.#
|
|
480
|
+
this.#appMenuSearchInput.addEventListener("input", (event) => {
|
|
481
481
|
this.#filterRoutes(event.target.value);
|
|
482
482
|
});
|
|
483
|
-
this.#
|
|
484
|
-
this.#
|
|
485
|
-
this.#
|
|
486
|
-
this.#
|
|
487
|
-
this.#appMenu.insertBefore(this.#
|
|
483
|
+
this.#appMenuSearchIcon.innerHTML = new Icon(Icons.ACTIONS.SEARCH, "15px").getHTML();
|
|
484
|
+
this.#appMenuSearchBox.appendChild(this.#appMenuSearchIcon);
|
|
485
|
+
this.#appMenuSearchBox.appendChild(this.#appMenuSearchInput);
|
|
486
|
+
this.#appMenuSearchMain.appendChild(this.#appMenuSearchBox);
|
|
487
|
+
this.#appMenu.insertBefore(this.#appMenuSearchMain, this.#routeViews);
|
|
488
488
|
}
|
|
489
489
|
// Пункты списка: подсказка о пустом результате лежит там же, поэтому фильтруем по тегу
|
|
490
490
|
#routes() {
|
|
@@ -500,10 +500,10 @@ class AppMenu {
|
|
|
500
500
|
item.style.display = matches ? "" : "none";
|
|
501
501
|
if (matches) visible++;
|
|
502
502
|
}
|
|
503
|
-
this.#
|
|
503
|
+
this.#appMenuEmpty.classList.toggle("app_menu_empty_visible", visible === 0);
|
|
504
504
|
}
|
|
505
505
|
go(page) {
|
|
506
|
-
|
|
506
|
+
const center = this.#center;
|
|
507
507
|
//
|
|
508
508
|
setPageName(page.getTitle());
|
|
509
509
|
center.innerHTML = "";
|
|
@@ -517,18 +517,18 @@ class AppMenu {
|
|
|
517
517
|
}
|
|
518
518
|
setRouteTest(page) {
|
|
519
519
|
this.#routeList.push(page);
|
|
520
|
-
|
|
520
|
+
const test = this.#routeList.filter((route) => route.getTitle().includes(page.getTitle()));
|
|
521
521
|
if (test.length === 1) {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
522
|
+
const buttonRoute = document.createElement("route");
|
|
523
|
+
const iconMenu = document.createElement("route_icon");
|
|
524
|
+
iconMenu.innerHTML = page.getIcon();
|
|
525
|
+
const titleMenu = document.createElement("route_title");
|
|
526
|
+
titleMenu.innerHTML = page.getTitle();
|
|
527
527
|
if (page.getMain()) {
|
|
528
528
|
this.go(page);
|
|
529
|
-
|
|
529
|
+
buttonRoute.classList.add("route_active");
|
|
530
530
|
}
|
|
531
|
-
|
|
531
|
+
buttonRoute.addEventListener("click", () => {
|
|
532
532
|
const eventAwesome = new CustomEvent("route_event_" + page.constructor.name, {
|
|
533
533
|
bubbles: true,
|
|
534
534
|
detail: {
|
|
@@ -539,11 +539,11 @@ class AppMenu {
|
|
|
539
539
|
});
|
|
540
540
|
document.dispatchEvent(eventAwesome);
|
|
541
541
|
|
|
542
|
-
if (!
|
|
542
|
+
if (!buttonRoute.classList.contains("route_active")) {
|
|
543
543
|
for (const act of this.#routes()) act.classList.remove("route_active");
|
|
544
544
|
this.go(page);
|
|
545
|
-
|
|
546
|
-
if (this.#
|
|
545
|
+
buttonRoute.classList.add("route_active");
|
|
546
|
+
if (this.#autoClose) {
|
|
547
547
|
// Панель закрывается после перехода: состояние и значок меняет тот же код,
|
|
548
548
|
// что и по Escape — иначе у кнопки осталось бы открытое aria-expanded
|
|
549
549
|
if (this.#appMenu.style.transform === `translateX(${this.#appMenuSlide}px)`) this.#close();
|
|
@@ -551,19 +551,19 @@ class AppMenu {
|
|
|
551
551
|
}
|
|
552
552
|
});
|
|
553
553
|
if (page.getIcon() !== undefined) {
|
|
554
|
-
|
|
554
|
+
buttonRoute.appendChild(iconMenu);
|
|
555
555
|
}
|
|
556
|
-
|
|
557
|
-
this.#routeViews.appendChild(
|
|
556
|
+
buttonRoute.appendChild(titleMenu);
|
|
557
|
+
this.#routeViews.appendChild(buttonRoute);
|
|
558
558
|
this.#routeKeyboard.refresh();
|
|
559
559
|
// Новый пункт мог не пройти уже введённый фильтр — список и подсказка пересобираются
|
|
560
|
-
this.#filterRoutes(this.#
|
|
560
|
+
this.#filterRoutes(this.#appMenuSearchInput.value);
|
|
561
561
|
} else {
|
|
562
562
|
Log.error(I18n.t("appLayout.search.alreadyAdded", { title: page.getTitle() }));
|
|
563
563
|
}
|
|
564
564
|
}
|
|
565
565
|
setAutoClose() {
|
|
566
|
-
this.#
|
|
566
|
+
this.#autoClose = true;
|
|
567
567
|
}
|
|
568
568
|
getMenu() {
|
|
569
569
|
return this.#appMenu;
|
|
@@ -571,97 +571,99 @@ class AppMenu {
|
|
|
571
571
|
}
|
|
572
572
|
|
|
573
573
|
class NotificationBox {
|
|
574
|
-
#
|
|
575
|
-
#
|
|
574
|
+
#notificationBox = document.createElement("notification_box");
|
|
575
|
+
#notificationBoxMain = document.createElement("notification_box_main");
|
|
576
576
|
// Шапка ящика: заголовок со счётчиком истории и действие «Очистить»
|
|
577
|
-
#
|
|
578
|
-
#
|
|
579
|
-
#
|
|
580
|
-
#
|
|
581
|
-
#
|
|
582
|
-
|
|
583
|
-
#
|
|
577
|
+
#notificationBoxHeader = document.createElement("notification_box_header");
|
|
578
|
+
#notificationBoxHeaderText = document.createElement("notification_box_header_text");
|
|
579
|
+
#notificationBoxTitle = document.createElement("notification_box_title");
|
|
580
|
+
#notificationBoxCount = document.createElement("notification_box_count");
|
|
581
|
+
#notificationBoxEmpty = document.createElement("notification_box_empty");
|
|
582
|
+
// Идёт каскад очистки: подписи дней и пустое состояние ждут его конца
|
|
583
|
+
#clearing = false;
|
|
584
|
+
#emptyState = undefined;
|
|
585
|
+
#clearButton = document.createElement("button");
|
|
584
586
|
// Состав ящика меняет Notification — за списком следит observer
|
|
585
587
|
#observer = undefined;
|
|
586
|
-
#
|
|
587
|
-
#
|
|
588
|
-
#
|
|
588
|
+
#notificationBoxWidth = 400;
|
|
589
|
+
#notificationBoxWidthTest = 425;
|
|
590
|
+
#notificationButton = document.createElement("notification_button");
|
|
589
591
|
constructor(header, center, layout) {
|
|
590
|
-
layout.set(LAYOUT_ROLE.NOTIFICATION_BOX, this.#
|
|
591
|
-
layout.set(LAYOUT_ROLE.NOTIFICATION_BOX_MAIN, this.#
|
|
592
|
-
this.#
|
|
592
|
+
layout.set(LAYOUT_ROLE.NOTIFICATION_BOX, this.#notificationBox);
|
|
593
|
+
layout.set(LAYOUT_ROLE.NOTIFICATION_BOX_MAIN, this.#notificationBoxMain);
|
|
594
|
+
this.#notificationBoxMain.id = "chui_notification_box";
|
|
593
595
|
// Шапка: заголовок, счётчик записей и очистка списка
|
|
594
|
-
this.#
|
|
595
|
-
this.#
|
|
596
|
-
this.#
|
|
597
|
-
this.#
|
|
596
|
+
this.#notificationBoxTitle.innerText = I18n.t("appLayout.notifications.title");
|
|
597
|
+
this.#notificationBoxCount.setAttribute("aria-hidden", "true");
|
|
598
|
+
this.#notificationBoxHeaderText.appendChild(this.#notificationBoxTitle);
|
|
599
|
+
this.#notificationBoxHeaderText.appendChild(this.#notificationBoxCount);
|
|
598
600
|
// Очистка — обычная кнопка-действие, а не Button: в шапке нужна тихая ссылка-действие
|
|
599
|
-
this.#
|
|
600
|
-
this.#
|
|
601
|
-
this.#
|
|
602
|
-
this.#
|
|
603
|
-
this.#
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
this.#
|
|
607
|
-
this.#
|
|
608
|
-
this.#
|
|
609
|
-
this.#
|
|
601
|
+
this.#clearButton.className = "notification_box_clear";
|
|
602
|
+
this.#clearButton.type = "button";
|
|
603
|
+
this.#clearButton.setAttribute("aria-label", I18n.t("appLayout.notifications.clearAll"));
|
|
604
|
+
this.#clearButton.setAttribute("title", I18n.t("appLayout.notifications.clear"));
|
|
605
|
+
this.#clearButton.appendChild(new Icon(Icons.ACTIONS.DELETE, "14px").set());
|
|
606
|
+
const clearTitle = document.createElement("span");
|
|
607
|
+
clearTitle.innerText = I18n.t("appLayout.notifications.clear");
|
|
608
|
+
this.#clearButton.appendChild(clearTitle);
|
|
609
|
+
this.#clearButton.addEventListener("click", () => this.#clearAll());
|
|
610
|
+
this.#notificationBoxHeader.appendChild(this.#notificationBoxHeaderText);
|
|
611
|
+
this.#notificationBoxHeader.appendChild(this.#clearButton);
|
|
610
612
|
// Пустое состояние — общий компонент, а не своя разметка из значка и подписи: это был
|
|
611
613
|
// третий вариант одного и того же блока (здесь, в поиске меню, в таблицах). Заодно он
|
|
612
614
|
// объявляется скринридеру (role="status"), а прежняя заглушка молчала
|
|
613
|
-
this.#
|
|
615
|
+
this.#emptyState = new EmptyState({
|
|
614
616
|
icon: Icons.SOCIAL.NOTIFICATIONS_OFF,
|
|
615
617
|
title: I18n.t("appLayout.notifications.empty"),
|
|
616
618
|
compact: true,
|
|
617
619
|
});
|
|
618
|
-
this.#
|
|
619
|
-
this.#
|
|
620
|
-
this.#
|
|
621
|
-
this.#
|
|
620
|
+
this.#notificationBoxEmpty.appendChild(this.#emptyState.set());
|
|
621
|
+
this.#notificationBoxMain.appendChild(this.#notificationBoxEmpty);
|
|
622
|
+
this.#notificationBox.appendChild(this.#notificationBoxHeader);
|
|
623
|
+
this.#notificationBox.appendChild(this.#notificationBoxMain);
|
|
622
624
|
// Карточки кладёт снаружи Notification, поэтому счётчик, группы и пустое состояние
|
|
623
625
|
// следим по списку. Разделители дней ящик вставляет сам: на них реакция не нужна,
|
|
624
626
|
// иначе пересборка дёргала бы сама себя без конца
|
|
625
627
|
this.#observer = new MutationObserver((records) => this.#onBoxMutated(records));
|
|
626
|
-
this.#observer.observe(this.#
|
|
628
|
+
this.#observer.observe(this.#notificationBoxMain, { childList: true });
|
|
627
629
|
this.#syncState();
|
|
628
|
-
this.#
|
|
629
|
-
this.#
|
|
630
|
-
this.#
|
|
631
|
-
if (this.#
|
|
630
|
+
this.#notificationBox.style.width = `${this.#notificationBoxWidth}px`;
|
|
631
|
+
this.#notificationBox.style.right = `calc(-${this.#notificationBoxWidthTest}px)`;
|
|
632
|
+
this.#notificationButton.addEventListener("click", () => {
|
|
633
|
+
if (this.#notificationButton.classList.contains("notification_button_active")) this.#closePanel();
|
|
632
634
|
else this.#openPanel();
|
|
633
635
|
});
|
|
634
636
|
// Значок — украшение: без подписи имя кнопки складывалось из лигатуры («notifications»)
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
this.#
|
|
637
|
+
const notificationGlyph = new Icon(Icons.SOCIAL.NOTIFICATIONS, "var(--header_icon_size)").set();
|
|
638
|
+
notificationGlyph.setAttribute("aria-hidden", "true");
|
|
639
|
+
this.#notificationButton.appendChild(notificationGlyph);
|
|
638
640
|
// Имя кнопки — в том числе «непрочитанных: N» — держит #syncUnread: одно место
|
|
639
641
|
// Клавиатура и aria панели уведомлений
|
|
640
|
-
makeKeyboardButton(this.#
|
|
641
|
-
this.#
|
|
642
|
-
this.#
|
|
643
|
-
this.#
|
|
644
|
-
this.#
|
|
645
|
-
this.#
|
|
646
|
-
this.#
|
|
642
|
+
makeKeyboardButton(this.#notificationButton);
|
|
643
|
+
this.#notificationButton.setAttribute("aria-haspopup", "true");
|
|
644
|
+
this.#notificationButton.setAttribute("aria-expanded", "false");
|
|
645
|
+
this.#notificationButton.setAttribute("aria-controls", "chui_notification_box");
|
|
646
|
+
this.#notificationBox.setAttribute("role", "region");
|
|
647
|
+
this.#notificationBox.setAttribute("aria-label", I18n.t("appLayout.notifications.title"));
|
|
648
|
+
this.#notificationBox.addEventListener("keydown", (event) => {
|
|
647
649
|
if (event.key !== "Escape") return;
|
|
648
650
|
event.preventDefault();
|
|
649
651
|
this.#closePanel();
|
|
650
|
-
this.#
|
|
652
|
+
this.#notificationButton.focus();
|
|
651
653
|
});
|
|
652
654
|
// Панель открывается с кнопки, и фокус остаётся на ней же — Escape обязан закрывать
|
|
653
655
|
// ящик и оттуда, иначе выйти с клавиатуры можно было только Tab'ом внутрь панели
|
|
654
|
-
this.#
|
|
656
|
+
this.#notificationButton.addEventListener("keydown", (event) => {
|
|
655
657
|
if (event.key !== "Escape") return;
|
|
656
658
|
event.preventDefault();
|
|
657
659
|
this.#closePanel();
|
|
658
660
|
});
|
|
659
661
|
header.set().addEventListener("click", (e) => {
|
|
660
|
-
if (e.target !== this.#
|
|
662
|
+
if (e.target !== this.#notificationButton) this.#closePanel();
|
|
661
663
|
});
|
|
662
664
|
center.addEventListener("click", (e) => {
|
|
663
|
-
if (!this.#
|
|
664
|
-
if (this.#
|
|
665
|
+
if (!this.#notificationBox.contains(e.target)) {
|
|
666
|
+
if (this.#notificationBox.style.transform === `translateX(-${this.#notificationBoxWidthTest}px)`)
|
|
665
667
|
this.#closePanel();
|
|
666
668
|
}
|
|
667
669
|
});
|
|
@@ -670,27 +672,27 @@ class NotificationBox {
|
|
|
670
672
|
// вид полосы целиком описан в global_style.css
|
|
671
673
|
header.set().classList.toggle("header_scrolled", center.scrollTop > 15);
|
|
672
674
|
};
|
|
673
|
-
header.addToRight(this.#
|
|
675
|
+
header.addToRight(this.#notificationButton);
|
|
674
676
|
}
|
|
675
677
|
#openPanel() {
|
|
676
|
-
this.#
|
|
677
|
-
this.#
|
|
678
|
-
this.#
|
|
678
|
+
this.#notificationBox.style.transform = `translateX(-${this.#notificationBoxWidthTest}px)`;
|
|
679
|
+
this.#notificationButton.classList.add("notification_button_active");
|
|
680
|
+
this.#notificationButton.setAttribute("aria-expanded", "true");
|
|
679
681
|
// Открытый ящик — значит прочитанный: отметки снимаются все сразу, а не по одной
|
|
680
682
|
this.#markRead();
|
|
681
683
|
}
|
|
682
684
|
#closePanel() {
|
|
683
|
-
this.#
|
|
684
|
-
this.#
|
|
685
|
-
this.#
|
|
685
|
+
this.#notificationBox.style.transform = `translateX(${this.#notificationBoxWidth}px)`;
|
|
686
|
+
this.#notificationButton.classList.remove("notification_button_active");
|
|
687
|
+
this.#notificationButton.setAttribute("aria-expanded", "false");
|
|
686
688
|
}
|
|
687
689
|
// Записи истории: карточки в ящик кладёт Notification, поэтому состав читаем из DOM
|
|
688
690
|
#notifications() {
|
|
689
|
-
return [...this.#
|
|
691
|
+
return [...this.#notificationBoxMain.children].filter((child) => child.tagName === "NOTIFICATION");
|
|
690
692
|
}
|
|
691
693
|
// Разделители дней — тоже прямые дети списка, но записями не являются
|
|
692
694
|
#groups() {
|
|
693
|
-
return [...this.#
|
|
695
|
+
return [...this.#notificationBoxMain.children].filter((child) => child.tagName === "NOTIFICATION_GROUP");
|
|
694
696
|
}
|
|
695
697
|
/*
|
|
696
698
|
* Реакция на изменение состава ящика.
|
|
@@ -709,10 +711,11 @@ class NotificationBox {
|
|
|
709
711
|
#syncState() {
|
|
710
712
|
const notifications = this.#notifications();
|
|
711
713
|
const count = notifications.length;
|
|
712
|
-
this.#
|
|
713
|
-
this.#
|
|
714
|
-
|
|
715
|
-
this.#
|
|
714
|
+
this.#notificationBoxCount.innerText = String(count);
|
|
715
|
+
this.#notificationBoxCount.style.display = count === 0 ? "none" : "flex";
|
|
716
|
+
// Пока идёт каскад, чистить нечего: кнопка ждёт его конца
|
|
717
|
+
this.#clearButton.disabled = count === 0 || this.#clearing;
|
|
718
|
+
this.#notificationBoxEmpty.classList.toggle("notification_box_empty_visible", count === 0);
|
|
716
719
|
this.#group(notifications);
|
|
717
720
|
this.#syncUnread(notifications);
|
|
718
721
|
}
|
|
@@ -725,12 +728,15 @@ class NotificationBox {
|
|
|
725
728
|
* по тегу (счётчик, очистка), работает без изменений.
|
|
726
729
|
*/
|
|
727
730
|
#group(notifications = []) {
|
|
731
|
+
// Идёт каскад уборки: подписи уже гаснут своим кадром, а пересборка заменила бы их
|
|
732
|
+
// новыми и вернула бы рывок, который каскад и убирает
|
|
733
|
+
if (this.#clearing) return;
|
|
728
734
|
for (const separator of this.#groups()) separator.remove();
|
|
729
735
|
let day = undefined;
|
|
730
736
|
for (const notification of notifications) {
|
|
731
737
|
if (notification.dataset.day === day) continue;
|
|
732
738
|
day = notification.dataset.day;
|
|
733
|
-
this.#
|
|
739
|
+
this.#notificationBoxMain.insertBefore(this.#makeGroup(day), notification);
|
|
734
740
|
}
|
|
735
741
|
}
|
|
736
742
|
/** Подпись дня: сегодня и вчера — словами, раньше — датой */
|
|
@@ -738,8 +744,8 @@ class NotificationBox {
|
|
|
738
744
|
const yesterday = new Date();
|
|
739
745
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
740
746
|
const group = document.createElement("notification_group");
|
|
741
|
-
if (day ===
|
|
742
|
-
else if (day ===
|
|
747
|
+
if (day === chuiFunctions.getDayKey()) group.innerText = I18n.t("appLayout.notifications.today");
|
|
748
|
+
else if (day === chuiFunctions.getDayKey(yesterday))
|
|
743
749
|
group.innerText = I18n.t("appLayout.notifications.yesterday");
|
|
744
750
|
else {
|
|
745
751
|
// Ключ хранится как ГГГГ-ММ-ДД, а в подписи порядок привычный: ДД.ММ.ГГГГ
|
|
@@ -757,27 +763,79 @@ class NotificationBox {
|
|
|
757
763
|
*/
|
|
758
764
|
#syncUnread(notifications = []) {
|
|
759
765
|
const unread = notifications.filter((notification) => notification.dataset.unread === "true").length;
|
|
760
|
-
this.#
|
|
766
|
+
this.#notificationButton.classList.toggle("notification_button_unread", unread > 0);
|
|
761
767
|
const label =
|
|
762
768
|
unread === 0
|
|
763
769
|
? I18n.t("appLayout.notifications.title")
|
|
764
770
|
: I18n.t("appLayout.notifications.unreadCount", { count: unread });
|
|
765
|
-
this.#
|
|
766
|
-
this.#
|
|
771
|
+
this.#notificationButton.setAttribute("aria-label", label);
|
|
772
|
+
this.#notificationButton.setAttribute("title", label);
|
|
767
773
|
}
|
|
768
774
|
#markRead() {
|
|
769
775
|
for (const notification of this.#notifications()) notification.dataset.unread = "false";
|
|
770
776
|
this.#syncUnread(this.#notifications());
|
|
771
777
|
}
|
|
778
|
+
/*
|
|
779
|
+
* Очистка истории.
|
|
780
|
+
*
|
|
781
|
+
* Карточки уходят каскадом: номер в очереди задаёт `--notification_clear_index`, а шаг —
|
|
782
|
+
* доля токена движения, поэтому весь список читается как рассыпающийся, а не как
|
|
783
|
+
* выключенный одним щелчком. Подписи дней гаснут вместе с ним, пустое состояние
|
|
784
|
+
* и счётчик появляются после последнего кадра.
|
|
785
|
+
*
|
|
786
|
+
* Конец каскада считает срок из тех же токенов движения, а не ждёт `animationend`:
|
|
787
|
+
* у карточки, которую браузер не отрисовывает (скрытая панель, `display: none`), кадр
|
|
788
|
+
* вообще не проигрывается, и событие не придёт — уборка осталась бы висеть
|
|
789
|
+
* незавершённой вместе с погашенными подписями.
|
|
790
|
+
*/
|
|
772
791
|
#clearAll() {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
792
|
+
// Повторное нажатие на ходу нача́ло бы второй каскад и перезапустило кадры
|
|
793
|
+
if (this.#clearing) return;
|
|
794
|
+
const notifications = this.#notifications();
|
|
795
|
+
if (notifications.length === 0) return;
|
|
796
|
+
// Уменьшенное движение: история очищается сразу — каскад только задерживал бы
|
|
797
|
+
if (reducedMotion()) {
|
|
798
|
+
for (const notification of notifications) notification.remove();
|
|
799
|
+
this.#syncState();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
this.#clearing = true;
|
|
803
|
+
this.#clearButton.disabled = true;
|
|
804
|
+
notifications.forEach((notification, index) => {
|
|
805
|
+
// Следы кадра прихода снимаем: инлайновое имя кадра сильнее класса, а вид движения
|
|
806
|
+
// спорит с ним по весу — без этого запись, только что вставшая в ящик, проиграла бы
|
|
807
|
+
// не кадр уборки, а свой въезд
|
|
808
|
+
notification.style.removeProperty("animation-name");
|
|
809
|
+
notification.removeAttribute("data-motion");
|
|
810
|
+
notification.style.setProperty("--notification_clear_index", String(index));
|
|
811
|
+
notification.classList.add("notification_clearing");
|
|
812
|
+
});
|
|
813
|
+
// Подписи дней гаснут вместе с карточками, а убираются только по концу каскада:
|
|
814
|
+
// иначе строка пропала бы из-под ещё уезжающих карточек
|
|
815
|
+
for (const group of this.#groups()) group.classList.add("notification_group_leaving");
|
|
816
|
+
const step = tokenTime("--motion_fast") / 4;
|
|
817
|
+
const frame = tokenTime("--motion_fast");
|
|
818
|
+
// Запас — ещё один шаг: кадр задерживается ещё и на запуск
|
|
819
|
+
const deadline = frame + step * notifications.length;
|
|
820
|
+
setTimeout(() => this.#finishClearing(notifications), deadline);
|
|
821
|
+
}
|
|
822
|
+
/*
|
|
823
|
+
* Конец каскада: убираем только те карточки, которые и были в очереди (за время уборки
|
|
824
|
+
* могла прийти новая — её очистка не касается), затем согласуем подписи, счётчик и подсказку
|
|
825
|
+
* по настоящему составу ящика.
|
|
826
|
+
*/
|
|
827
|
+
#finishClearing(notifications = []) {
|
|
828
|
+
this.#clearing = false;
|
|
829
|
+
for (const notification of notifications) {
|
|
830
|
+
notification.classList.remove("notification_clearing");
|
|
831
|
+
notification.style.removeProperty("--notification_clear_index");
|
|
832
|
+
notification.remove();
|
|
777
833
|
}
|
|
834
|
+
for (const group of this.#groups()) group.remove();
|
|
835
|
+
this.#syncState();
|
|
778
836
|
}
|
|
779
837
|
getBox() {
|
|
780
|
-
return this.#
|
|
838
|
+
return this.#notificationBox;
|
|
781
839
|
}
|
|
782
840
|
}
|
|
783
841
|
|
|
@@ -822,11 +880,11 @@ class AppLayout {
|
|
|
822
880
|
constructor() {
|
|
823
881
|
require("../../modules/chui_fonts").install();
|
|
824
882
|
// Глобальные стили
|
|
825
|
-
|
|
826
|
-
|
|
883
|
+
chuiFunctions.setStyles(__dirname + `/global_style.css`, "chUiJS_Global_App");
|
|
884
|
+
chuiFunctions.setStyles(__dirname + `/main_theme_style.css`, "chUiJS_Main_Theme");
|
|
827
885
|
// Необработанные ошибки и reject’ы renderer-процесса — один раз на процесс
|
|
828
|
-
if (
|
|
829
|
-
|
|
886
|
+
if (appErrorHandlersInstalled === false) {
|
|
887
|
+
appErrorHandlersInstalled = true;
|
|
830
888
|
window.addEventListener("error", this.#onWindowError);
|
|
831
889
|
window.addEventListener("unhandledrejection", this.#onUnhandledRejection);
|
|
832
890
|
}
|
|
@@ -877,16 +935,16 @@ class AppLayout {
|
|
|
877
935
|
ipcRenderer.on("sendNotificationUpdateClose", () => {
|
|
878
936
|
if (this.#updateNotification !== undefined) this.#updateNotification.hide();
|
|
879
937
|
});
|
|
880
|
-
|
|
938
|
+
const popup = new Popup();
|
|
881
939
|
ipcRenderer.once("checkUpdatesTrue", async (e, check, version) => {
|
|
882
940
|
if (check) {
|
|
883
|
-
|
|
941
|
+
const confirmRes = await popup.confirm({
|
|
884
942
|
title: I18n.t("appLayout.update.available", { version: version }),
|
|
885
943
|
message: I18n.t("appLayout.update.question"),
|
|
886
944
|
cancelText: I18n.t("appLayout.update.cancel"),
|
|
887
945
|
okText: I18n.t("appLayout.update.install"),
|
|
888
946
|
});
|
|
889
|
-
e.sender.send("updateInstallConfirm",
|
|
947
|
+
e.sender.send("updateInstallConfirm", confirmRes);
|
|
890
948
|
}
|
|
891
949
|
});
|
|
892
950
|
}
|
|
@@ -920,8 +978,8 @@ class AppLayout {
|
|
|
920
978
|
this.#layout.get(LAYOUT_ROLE.PAGE_NAME).classList.add("page_name_no_menu");
|
|
921
979
|
}
|
|
922
980
|
setScript(pathToJS = String(), id = String()) {
|
|
923
|
-
|
|
924
|
-
|
|
981
|
+
const data = fs.readFileSync(pathToJS, "utf8");
|
|
982
|
+
const script = document.createElement("script");
|
|
925
983
|
script.innerHTML = String(data);
|
|
926
984
|
script.setAttribute("id", id);
|
|
927
985
|
// Скрипт подключается в сам документ (не узел компонента), поэтому проверка по id и document
|
|
@@ -990,21 +1048,21 @@ class AppLayout {
|
|
|
990
1048
|
}
|
|
991
1049
|
|
|
992
1050
|
class HeaderTabs {
|
|
993
|
-
#
|
|
994
|
-
#
|
|
995
|
-
#
|
|
996
|
-
#
|
|
1051
|
+
#idList = require("randomstring").generate();
|
|
1052
|
+
#headerTabsList = undefined;
|
|
1053
|
+
#headerTabs = document.createElement("header_tabs");
|
|
1054
|
+
#keydownEvent = (event) => this.#onKeydown(event);
|
|
997
1055
|
constructor(options = { width: String(), default: Number(), tabs: [] }) {
|
|
998
|
-
this.#
|
|
999
|
-
this.#
|
|
1056
|
+
this.#headerTabsList = options.tabs;
|
|
1057
|
+
this.#headerTabs.id = this.#idList;
|
|
1000
1058
|
// Полоса вкладок — не меню: у неё свои роли (`tablist`/`tab`) и свои клавиши (см. #onKeydown).
|
|
1001
1059
|
// `role="button"` и `tabindex="0"` каждой вкладке уже поставил makeKeyboardButton, поэтому
|
|
1002
1060
|
// роли и порядок обхода переопределяем здесь: остановка Tab в полосе должна быть ровно одна
|
|
1003
|
-
this.#
|
|
1004
|
-
for (
|
|
1061
|
+
this.#headerTabs.setAttribute("role", "tablist");
|
|
1062
|
+
for (const item of this.#headerTabsList) {
|
|
1005
1063
|
item.setAttribute("role", "tab");
|
|
1006
1064
|
item.setAttribute("tabindex", "-1");
|
|
1007
|
-
this.#
|
|
1065
|
+
this.#headerTabs.appendChild(item);
|
|
1008
1066
|
item.addEventListener("click", (event) => {
|
|
1009
1067
|
if (event.target.tagName === "HEADER_BUTTON_ICON" || event.target.tagName === "HEADER_BUTTON_TITLE") {
|
|
1010
1068
|
this.#setActive(event.target.parentNode);
|
|
@@ -1019,7 +1077,7 @@ class HeaderTabs {
|
|
|
1019
1077
|
// кликом он снимал у неё и курсор-указатель, и ховер, которых у соседних кнопок шапки
|
|
1020
1078
|
// нет. Слушатель ловит клик в фазе перехвата: действие вкладки и `#setActive` висят
|
|
1021
1079
|
// на самом элементе и иначе сработали бы первыми
|
|
1022
|
-
this.#
|
|
1080
|
+
this.#headerTabs.addEventListener(
|
|
1023
1081
|
"click",
|
|
1024
1082
|
(event) => {
|
|
1025
1083
|
const tab = event.target.closest === undefined ? null : event.target.closest("header_button");
|
|
@@ -1030,12 +1088,12 @@ class HeaderTabs {
|
|
|
1030
1088
|
);
|
|
1031
1089
|
// Пока ни одна вкладка не выбрана, остановкой обхода становится первая по счёту:
|
|
1032
1090
|
// иначе в полосу нельзя было бы войти с клавиатуры вообще
|
|
1033
|
-
const
|
|
1034
|
-
if (
|
|
1035
|
-
this.#
|
|
1036
|
-
if (options.width !== undefined) this.#
|
|
1037
|
-
for (
|
|
1038
|
-
if (this.#
|
|
1091
|
+
const defaultTab = this.#headerTabsList[options.default === undefined ? 0 : options.default];
|
|
1092
|
+
if (defaultTab !== undefined) defaultTab.setAttribute("tabindex", "0");
|
|
1093
|
+
this.#headerTabs.addEventListener("keydown", this.#keydownEvent);
|
|
1094
|
+
if (options.width !== undefined) this.#headerTabs.style.width = options.width;
|
|
1095
|
+
for (const item of this.#headerTabsList) {
|
|
1096
|
+
if (this.#headerTabsList.indexOf(item) === options.default) {
|
|
1039
1097
|
setTimeout(() => {
|
|
1040
1098
|
item.click();
|
|
1041
1099
|
//this.#setActive(item)
|
|
@@ -1044,13 +1102,13 @@ class HeaderTabs {
|
|
|
1044
1102
|
}
|
|
1045
1103
|
}
|
|
1046
1104
|
set() {
|
|
1047
|
-
return this.#
|
|
1105
|
+
return this.#headerTabs;
|
|
1048
1106
|
}
|
|
1049
1107
|
#setActive(target) {
|
|
1050
1108
|
if (target.getAttribute("active") === null) {
|
|
1051
1109
|
// `children`, а не `childNodes`: в `childNodes` попадают и текстовые узлы, у которых нет classList.
|
|
1052
1110
|
// `children` — HTMLCollection, поэтому перебираем копию
|
|
1053
|
-
[...this.#
|
|
1111
|
+
[...this.#headerTabs.children].forEach((child) => {
|
|
1054
1112
|
child.removeAttribute("active");
|
|
1055
1113
|
child.removeAttribute("aria-current");
|
|
1056
1114
|
child.removeAttribute("aria-selected");
|
|
@@ -1077,7 +1135,7 @@ class HeaderTabs {
|
|
|
1077
1135
|
* `makeKeyboardButton` (клик по выбранной вкладке гасит перехватчик выше).
|
|
1078
1136
|
*/
|
|
1079
1137
|
#onKeydown(event) {
|
|
1080
|
-
const tabs = [...this.#
|
|
1138
|
+
const tabs = [...this.#headerTabs.children];
|
|
1081
1139
|
if (tabs.length === 0) return;
|
|
1082
1140
|
const focused = tabs.indexOf(document.activeElement);
|
|
1083
1141
|
// Фокус мог быть не в полосе: тогда считаем от вкладки с остановкой обхода
|
|
@@ -1100,12 +1158,12 @@ class UserProfile {
|
|
|
1100
1158
|
// находится по узлу обёртки — так же, как у MenuBar, и по нему его снимает тот,
|
|
1101
1159
|
// кто убирает профиль из шапки (`Header.removeToRight`)
|
|
1102
1160
|
static #instances = new WeakMap();
|
|
1103
|
-
#
|
|
1104
|
-
#
|
|
1105
|
-
#
|
|
1106
|
-
#
|
|
1161
|
+
#userMain = document.createElement("user_main");
|
|
1162
|
+
#userButton = document.createElement("user_button");
|
|
1163
|
+
#userDropdown = document.createElement("user_dropdown");
|
|
1164
|
+
#isOpen = false;
|
|
1107
1165
|
//
|
|
1108
|
-
#
|
|
1166
|
+
#userDdImageMain = document.createElement("user_dd_image_main");
|
|
1109
1167
|
// Аватар — компонент Avatar: инициалы, картинка из адреса/base64/файла
|
|
1110
1168
|
#avatar = undefined;
|
|
1111
1169
|
#username = undefined;
|
|
@@ -1113,15 +1171,15 @@ class UserProfile {
|
|
|
1113
1171
|
// Слушатель window нужен только пока меню открыто: иначе он копился на каждый профиль.
|
|
1114
1172
|
// Закрывает меню только по клику СНАРУЖИ: раньше сравнивался прямой родитель цели,
|
|
1115
1173
|
// поэтому клик по аватару или пустому месту внутри меню тоже его закрывал
|
|
1116
|
-
#
|
|
1117
|
-
if (this.#
|
|
1174
|
+
#windowClickEvent = (event) => {
|
|
1175
|
+
if (this.#userMain.contains(event.target) || this.#userDropdown.contains(event.target)) return;
|
|
1118
1176
|
this.#closeMenu();
|
|
1119
1177
|
};
|
|
1120
1178
|
// Выбор пункта закрывает меню. Обработчик на списке, а не на каждом пункте: пункты
|
|
1121
1179
|
// приходят из UserDDItem и могут быть добавлены кем угодно снаружи
|
|
1122
|
-
#
|
|
1180
|
+
#dropdownClickEvent = (event) => {
|
|
1123
1181
|
const item = event.target.closest === undefined ? null : event.target.closest("user_item");
|
|
1124
|
-
if (item === null || !this.#
|
|
1182
|
+
if (item === null || !this.#userDropdown.contains(item)) return;
|
|
1125
1183
|
this.#closeMenu();
|
|
1126
1184
|
};
|
|
1127
1185
|
//
|
|
@@ -1133,15 +1191,15 @@ class UserProfile {
|
|
|
1133
1191
|
}
|
|
1134
1192
|
) {
|
|
1135
1193
|
this.#username = options.username;
|
|
1136
|
-
this.#
|
|
1137
|
-
this.#
|
|
1138
|
-
UserProfile.#instances.set(this.#
|
|
1194
|
+
this.#userButton.innerText = options.username;
|
|
1195
|
+
this.#userMain.appendChild(this.#userButton);
|
|
1196
|
+
UserProfile.#instances.set(this.#userMain, this);
|
|
1139
1197
|
// Панель — слой в body: внутри шапки (у неё свой backdrop-filter) её blur не работал бы,
|
|
1140
1198
|
// а выдвижные слои оболочки перекрывали бы её. Идентификатор нужен для aria-controls
|
|
1141
|
-
this.#
|
|
1142
|
-
document.body.appendChild(this.#
|
|
1143
|
-
this.#
|
|
1144
|
-
if (this.#
|
|
1199
|
+
this.#userDropdown.id = require("randomstring").generate();
|
|
1200
|
+
document.body.appendChild(this.#userDropdown);
|
|
1201
|
+
this.#userButton.addEventListener("click", () => {
|
|
1202
|
+
if (this.#isOpen) this.#closeMenu();
|
|
1145
1203
|
else this.#openMenu();
|
|
1146
1204
|
});
|
|
1147
1205
|
//
|
|
@@ -1153,22 +1211,22 @@ class UserProfile {
|
|
|
1153
1211
|
size: "80px",
|
|
1154
1212
|
decorative: true,
|
|
1155
1213
|
});
|
|
1156
|
-
this.#
|
|
1157
|
-
this.#
|
|
1214
|
+
this.#userDdImageMain.appendChild(this.#avatar.set());
|
|
1215
|
+
this.#userDropdown.appendChild(this.#userDdImageMain);
|
|
1158
1216
|
}
|
|
1159
1217
|
//
|
|
1160
|
-
for (
|
|
1161
|
-
this.#
|
|
1218
|
+
for (const item of options.items) this.#userDropdown.appendChild(item);
|
|
1219
|
+
this.#userDropdown.addEventListener("click", this.#dropdownClickEvent);
|
|
1162
1220
|
// Клавиатура и aria меню профиля: стрелки по пунктам, Escape закрывает и возвращает фокус
|
|
1163
|
-
this.#menuKeyboard = new MenuKeyboard(this.#
|
|
1221
|
+
this.#menuKeyboard = new MenuKeyboard(this.#userDropdown, {
|
|
1164
1222
|
itemSelector: "user_item",
|
|
1165
1223
|
onEscape: () => this.#closeMenu(),
|
|
1166
|
-
trigger: this.#
|
|
1224
|
+
trigger: this.#userButton,
|
|
1167
1225
|
});
|
|
1168
|
-
makeKeyboardButton(this.#
|
|
1226
|
+
makeKeyboardButton(this.#userButton);
|
|
1169
1227
|
// Закрытое меню объявляется закрытым сразу: иначе у кнопки не было бы aria-expanded
|
|
1170
1228
|
this.#menuKeyboard.setExpanded(false);
|
|
1171
|
-
this.#
|
|
1229
|
+
this.#userButton.addEventListener("keydown", (event) => {
|
|
1172
1230
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
1173
1231
|
event.preventDefault();
|
|
1174
1232
|
this.#openMenu();
|
|
@@ -1177,40 +1235,40 @@ class UserProfile {
|
|
|
1177
1235
|
});
|
|
1178
1236
|
}
|
|
1179
1237
|
#openMenu() {
|
|
1180
|
-
this.#
|
|
1238
|
+
this.#isOpen = true;
|
|
1181
1239
|
// Панель живёт в body: если её снимал `unmount`, возвращаем на место
|
|
1182
|
-
if (document.body.contains(this.#
|
|
1183
|
-
this.#
|
|
1240
|
+
if (document.body.contains(this.#userDropdown) === false) document.body.appendChild(this.#userDropdown);
|
|
1241
|
+
this.#userButton.classList.add(USER_PROFILE_ACTIVE_CLASS);
|
|
1184
1242
|
// fadeIn показывает панель (display: flex), и только после этого её можно измерить
|
|
1185
|
-
new Animation(this.#
|
|
1243
|
+
new Animation(this.#userDropdown).fadeIn();
|
|
1186
1244
|
this.#positionDropdown();
|
|
1187
|
-
window.addEventListener("click", this.#
|
|
1188
|
-
window.addEventListener("resize", this.#
|
|
1245
|
+
window.addEventListener("click", this.#windowClickEvent);
|
|
1246
|
+
window.addEventListener("resize", this.#positionDropdownEvent);
|
|
1189
1247
|
this.#menuKeyboard.setExpanded(true);
|
|
1190
1248
|
}
|
|
1191
1249
|
#closeMenu() {
|
|
1192
|
-
if (!this.#
|
|
1193
|
-
this.#
|
|
1194
|
-
this.#
|
|
1195
|
-
new Animation(this.#
|
|
1196
|
-
window.removeEventListener("click", this.#
|
|
1197
|
-
window.removeEventListener("resize", this.#
|
|
1250
|
+
if (!this.#isOpen) return;
|
|
1251
|
+
this.#isOpen = false;
|
|
1252
|
+
this.#userButton.classList.remove(USER_PROFILE_ACTIVE_CLASS);
|
|
1253
|
+
new Animation(this.#userDropdown).fadeOut();
|
|
1254
|
+
window.removeEventListener("click", this.#windowClickEvent);
|
|
1255
|
+
window.removeEventListener("resize", this.#positionDropdownEvent);
|
|
1198
1256
|
this.#menuKeyboard.setExpanded(false);
|
|
1199
1257
|
}
|
|
1200
1258
|
// position: fixed — координаты считаются от окна, поэтому панель пересчитывается и на resize.
|
|
1201
1259
|
// Логический `inset-inline-end` держит выравнивание по кнопке и в RTL
|
|
1202
1260
|
#positionDropdown() {
|
|
1203
|
-
const button = this.#
|
|
1204
|
-
const dropdown = this.#
|
|
1261
|
+
const button = this.#userButton.getBoundingClientRect();
|
|
1262
|
+
const dropdown = this.#userDropdown.getBoundingClientRect();
|
|
1205
1263
|
let end = window.innerWidth - button.right;
|
|
1206
1264
|
if (end + dropdown.width > window.innerWidth - USER_DROPDOWN_EDGE) {
|
|
1207
1265
|
end = window.innerWidth - USER_DROPDOWN_EDGE - dropdown.width;
|
|
1208
1266
|
}
|
|
1209
1267
|
if (end < USER_DROPDOWN_EDGE) end = USER_DROPDOWN_EDGE;
|
|
1210
|
-
this.#
|
|
1211
|
-
this.#
|
|
1268
|
+
this.#userDropdown.style.insetInlineEnd = `${Math.round(end)}px`;
|
|
1269
|
+
this.#userDropdown.style.top = `${Math.round(button.bottom + USER_DROPDOWN_GAP)}px`;
|
|
1212
1270
|
}
|
|
1213
|
-
#
|
|
1271
|
+
#positionDropdownEvent = () => this.#positionDropdown();
|
|
1214
1272
|
|
|
1215
1273
|
getUsername() {
|
|
1216
1274
|
return this.#username;
|
|
@@ -1228,8 +1286,8 @@ class UserProfile {
|
|
|
1228
1286
|
*/
|
|
1229
1287
|
unmount() {
|
|
1230
1288
|
this.#closeMenu();
|
|
1231
|
-
this.#
|
|
1232
|
-
UserProfile.#instances.delete(this.#
|
|
1289
|
+
this.#userDropdown.remove();
|
|
1290
|
+
UserProfile.#instances.delete(this.#userMain);
|
|
1233
1291
|
return this;
|
|
1234
1292
|
}
|
|
1235
1293
|
/** Полное удаление: панель больше не нужна — снять клавиатуру и убрать панель из body */
|
|
@@ -1249,12 +1307,12 @@ class UserProfile {
|
|
|
1249
1307
|
}
|
|
1250
1308
|
|
|
1251
1309
|
set() {
|
|
1252
|
-
return this.#
|
|
1310
|
+
return this.#userMain;
|
|
1253
1311
|
}
|
|
1254
1312
|
}
|
|
1255
1313
|
|
|
1256
1314
|
class UserDDItem {
|
|
1257
|
-
#
|
|
1315
|
+
#userItem = document.createElement("user_item");
|
|
1258
1316
|
|
|
1259
1317
|
constructor(
|
|
1260
1318
|
options = {
|
|
@@ -1264,26 +1322,26 @@ class UserDDItem {
|
|
|
1264
1322
|
}
|
|
1265
1323
|
) {
|
|
1266
1324
|
if (options.title !== undefined && options.icon !== undefined) {
|
|
1267
|
-
this.#
|
|
1325
|
+
this.#userItem.innerText = options.title;
|
|
1268
1326
|
// Значок ведёт строку — как в пунктах context_menu_item и выпадающих списков
|
|
1269
1327
|
// меню-бара. Дописанный в конец, он прилипал к подписи, и пункт меню профиля
|
|
1270
1328
|
// выбивался из семьи меню (в остальных меню значок стоит перед текстом)
|
|
1271
|
-
this.#
|
|
1329
|
+
this.#userItem.insertAdjacentHTML("afterbegin", new Icon(options.icon).getHTML());
|
|
1272
1330
|
} else if (options.title !== undefined) {
|
|
1273
|
-
this.#
|
|
1331
|
+
this.#userItem.innerText = options.title;
|
|
1274
1332
|
} else if (options.icon !== undefined) {
|
|
1275
|
-
this.#
|
|
1333
|
+
this.#userItem.innerHTML = new Icon(options.icon).getHTML();
|
|
1276
1334
|
}
|
|
1277
|
-
this.#
|
|
1335
|
+
this.#userItem.addEventListener("click", options.clickEvent);
|
|
1278
1336
|
}
|
|
1279
1337
|
|
|
1280
1338
|
set() {
|
|
1281
|
-
return this.#
|
|
1339
|
+
return this.#userItem;
|
|
1282
1340
|
}
|
|
1283
1341
|
}
|
|
1284
1342
|
|
|
1285
1343
|
class HeaderDialog {
|
|
1286
|
-
#
|
|
1344
|
+
#headerButton = undefined;
|
|
1287
1345
|
constructor(
|
|
1288
1346
|
options = {
|
|
1289
1347
|
title: String(),
|
|
@@ -1308,72 +1366,72 @@ class HeaderDialog {
|
|
|
1308
1366
|
// Неполные `dialogOptions` сборку не роняют: `style` и `components` — необязательные
|
|
1309
1367
|
// части опции, а прямое обращение к ним давало TypeError при создании кнопки
|
|
1310
1368
|
// («Cannot read properties of undefined») ещё до первого открытия слоя
|
|
1311
|
-
const
|
|
1312
|
-
const
|
|
1313
|
-
const
|
|
1369
|
+
const dialogOptions = options.dialogOptions === undefined ? {} : options.dialogOptions;
|
|
1370
|
+
const dialogStyle = dialogOptions.style === undefined ? {} : dialogOptions.style;
|
|
1371
|
+
const dialogComponents = dialogOptions.components === undefined ? [] : dialogOptions.components;
|
|
1314
1372
|
// Пустой заголовок остаётся пустым: заготовка опции рисовала бы слово «undefined»
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
width:
|
|
1319
|
-
height:
|
|
1320
|
-
closeOutSideClick:
|
|
1373
|
+
const dialogTitle =
|
|
1374
|
+
dialogOptions.title === undefined || dialogOptions.title === null ? "" : dialogOptions.title;
|
|
1375
|
+
const dialog = new Dialog({
|
|
1376
|
+
width: dialogStyle.width,
|
|
1377
|
+
height: dialogStyle.height,
|
|
1378
|
+
closeOutSideClick: dialogOptions.closeOutSideClick,
|
|
1321
1379
|
// Имя модального слоя для скринридера: без него диалог объявлялся бы безымянным
|
|
1322
|
-
title:
|
|
1380
|
+
title: dialogTitle,
|
|
1323
1381
|
});
|
|
1324
1382
|
// HEADER
|
|
1325
|
-
|
|
1383
|
+
const headerDialog = new ContentBlock({
|
|
1326
1384
|
direction: "row",
|
|
1327
1385
|
wrap: "nowrap",
|
|
1328
1386
|
align: "center",
|
|
1329
1387
|
justify: "space-between",
|
|
1330
1388
|
disableMarginChild: true,
|
|
1331
1389
|
});
|
|
1332
|
-
|
|
1390
|
+
headerDialog.setWidth("-webkit-fill-available");
|
|
1333
1391
|
// Заголовок — как у заголовков панелей оболочки (размер `--font_default_size`, вес 500
|
|
1334
1392
|
// даёт `strong`): отдельные 14 pt в диалоге спорили с его содержимым
|
|
1335
|
-
|
|
1336
|
-
markdownText: `**${
|
|
1393
|
+
const title = new Label({
|
|
1394
|
+
markdownText: `**${dialogTitle}**`,
|
|
1337
1395
|
wordBreak: "normal",
|
|
1338
1396
|
width: "max-content",
|
|
1339
1397
|
});
|
|
1340
|
-
|
|
1398
|
+
const closeButton = new Button({
|
|
1341
1399
|
icon: Icons.NAVIGATION.CLOSE,
|
|
1342
1400
|
label: I18n.t("common.close"),
|
|
1343
1401
|
reverse: true,
|
|
1344
1402
|
clickEvent: () => dialog.close(),
|
|
1345
1403
|
});
|
|
1346
|
-
|
|
1347
|
-
dialog.addToHeader(
|
|
1404
|
+
headerDialog.add(title, closeButton);
|
|
1405
|
+
dialog.addToHeader(headerDialog);
|
|
1348
1406
|
// BODY
|
|
1349
|
-
|
|
1350
|
-
direction:
|
|
1351
|
-
wrap:
|
|
1352
|
-
align:
|
|
1353
|
-
justify:
|
|
1407
|
+
const bodyDialog = new ContentBlock({
|
|
1408
|
+
direction: dialogStyle.direction,
|
|
1409
|
+
wrap: dialogStyle.wrap,
|
|
1410
|
+
align: dialogStyle.align,
|
|
1411
|
+
justify: dialogStyle.justify,
|
|
1354
1412
|
});
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
dialog.addToBody(
|
|
1413
|
+
bodyDialog.setWidth("-webkit-fill-available");
|
|
1414
|
+
bodyDialog.add(...dialogComponents);
|
|
1415
|
+
dialog.addToBody(bodyDialog);
|
|
1358
1416
|
// FOOTER
|
|
1359
1417
|
|
|
1360
1418
|
//
|
|
1361
|
-
this.#
|
|
1419
|
+
this.#headerButton = makeHeaderButtonBox(options);
|
|
1362
1420
|
// Слой идёт в body при первом открытии, а не при сборке: заранее созданный
|
|
1363
1421
|
// невидимый диалог оставался бы в документе на всё время жизни кнопки
|
|
1364
|
-
this.#
|
|
1422
|
+
this.#headerButton.addEventListener("click", () => {
|
|
1365
1423
|
if (document.body.contains(dialog.set()) === false) document.body.appendChild(dialog.set());
|
|
1366
1424
|
dialog.open();
|
|
1367
1425
|
});
|
|
1368
1426
|
}
|
|
1369
1427
|
|
|
1370
1428
|
set() {
|
|
1371
|
-
return this.#
|
|
1429
|
+
return this.#headerButton;
|
|
1372
1430
|
}
|
|
1373
1431
|
}
|
|
1374
1432
|
|
|
1375
1433
|
class HeaderButton {
|
|
1376
|
-
#
|
|
1434
|
+
#headerButton = undefined;
|
|
1377
1435
|
|
|
1378
1436
|
constructor(
|
|
1379
1437
|
options = {
|
|
@@ -1384,12 +1442,12 @@ class HeaderButton {
|
|
|
1384
1442
|
clickEvent: () => {},
|
|
1385
1443
|
}
|
|
1386
1444
|
) {
|
|
1387
|
-
this.#
|
|
1388
|
-
this.#
|
|
1445
|
+
this.#headerButton = makeHeaderButtonBox(options);
|
|
1446
|
+
this.#headerButton.addEventListener("click", options.clickEvent);
|
|
1389
1447
|
}
|
|
1390
1448
|
|
|
1391
1449
|
set() {
|
|
1392
|
-
return this.#
|
|
1450
|
+
return this.#headerButton;
|
|
1393
1451
|
}
|
|
1394
1452
|
}
|
|
1395
1453
|
|