chuijs 4.0.2 → 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 +597 -0
- package/README.md +43 -644
- package/framework/appLayout/default/chui_app_layout.js +602 -496
- package/framework/appLayout/default/global_style.css +288 -97
- package/framework/appLayout/default/main_theme_style.css +188 -101
- package/framework/components/chui_accordion/accordion.js +38 -35
- package/framework/components/chui_accordion/styles.css +13 -12
- package/framework/components/chui_action_sheet/action_sheet.js +93 -64
- package/framework/components/chui_action_sheet/styles.css +12 -5
- 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 +16 -11
- package/framework/components/chui_affix/styles.css +5 -0
- package/framework/components/chui_alert/alert.js +34 -19
- package/framework/components/chui_alert/styles.css +7 -0
- package/framework/components/chui_avatar/avatar.js +36 -35
- package/framework/components/chui_avatar/styles.css +1 -1
- package/framework/components/chui_avatar_group/avatar_group.js +25 -23
- package/framework/components/chui_avatar_group/styles.css +1 -1
- package/framework/components/chui_backtop/backtop.js +60 -21
- package/framework/components/chui_backtop/styles.css +21 -0
- package/framework/components/chui_badge/badge.js +30 -28
- 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/breadcrumb.js +15 -13
- package/framework/components/chui_breadcrumb/styles.css +1 -1
- package/framework/components/chui_button/button.js +52 -36
- package/framework/components/chui_button/styles.css +7 -7
- package/framework/components/chui_calendar/calendar.js +18 -16
- package/framework/components/chui_card/card.js +52 -32
- 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 +140 -79
- package/framework/components/chui_chat/styles.css +18 -7
- package/framework/components/chui_clipboard_history/clipboard_history.js +53 -39
- package/framework/components/chui_clipboard_history/styles.css +13 -9
- package/framework/components/chui_code/code.js +10 -10
- package/framework/components/chui_code/styles.css +4 -4
- package/framework/components/chui_code_editor/code_editor.js +73 -57
- package/framework/components/chui_code_editor/styles.css +6 -5
- package/framework/components/chui_command_palette/command_palette.js +87 -64
- package/framework/components/chui_command_palette/styles.css +5 -5
- package/framework/components/chui_console/console.js +13 -13
- package/framework/components/chui_console/styles.css +6 -3
- package/framework/components/chui_content_block/content_block.js +35 -21
- package/framework/components/chui_content_block/styles.css +2 -2
- package/framework/components/chui_context_menu/context_menu.js +171 -58
- package/framework/components/chui_context_menu/styles.css +82 -8
- package/framework/components/chui_custom_element/custom_element.js +25 -18
- package/framework/components/chui_data_grid/data_grid.js +633 -471
- package/framework/components/chui_data_grid/styles.css +24 -22
- package/framework/components/chui_descriptions/descriptions.js +34 -17
- package/framework/components/chui_descriptions/styles.css +1 -1
- package/framework/components/chui_details/details.js +42 -36
- package/framework/components/chui_details/styles.css +12 -11
- package/framework/components/chui_diff_viewer/diff_viewer.js +172 -147
- package/framework/components/chui_diff_viewer/styles.css +10 -10
- package/framework/components/chui_divider/divider.js +19 -17
- package/framework/components/chui_divider/styles.css +2 -2
- package/framework/components/chui_drawer/drawer.js +63 -38
- package/framework/components/chui_drawer/styles.css +18 -4
- package/framework/components/chui_drop_zone/drop_zone.js +37 -27
- package/framework/components/chui_drop_zone/styles.css +10 -6
- package/framework/components/chui_empty_state/empty_state.js +20 -15
- package/framework/components/chui_fieldset/fieldset.js +40 -23
- package/framework/components/chui_fieldset/styles.css +6 -6
- package/framework/components/chui_file_explorer/file_explorer.js +260 -189
- package/framework/components/chui_file_explorer/styles.css +8 -7
- package/framework/components/chui_form/form.js +40 -28
- package/framework/components/chui_form/styles.css +1 -1
- package/framework/components/chui_gantt/gantt.js +102 -78
- package/framework/components/chui_gantt/styles.css +5 -5
- package/framework/components/chui_graphs/graphs.js +376 -239
- package/framework/components/chui_graphs/styles.css +27 -6
- package/framework/components/chui_hero/hero.js +98 -37
- package/framework/components/chui_hero/styles.css +176 -7
- package/framework/components/chui_html_block/html_block.js +8 -8
- package/framework/components/chui_html_block/styles.css +3 -3
- package/framework/components/chui_hx/hx.js +3 -3
- package/framework/components/chui_hx/styles.css +13 -6
- package/framework/components/chui_icons/icons.js +2115 -2115
- package/framework/components/chui_icons/styles.css +4 -3
- package/framework/components/chui_inputs/chui_check_box/check_box.js +31 -19
- package/framework/components/chui_inputs/chui_check_box/styles.css +30 -21
- package/framework/components/chui_inputs/chui_chips/chips.js +141 -70
- package/framework/components/chui_inputs/chui_chips/styles.css +32 -7
- package/framework/components/chui_inputs/chui_color_picker/color_picker.js +55 -38
- package/framework/components/chui_inputs/chui_color_picker/styles.css +7 -7
- package/framework/components/chui_inputs/chui_combo_box/combo_box.js +127 -109
- package/framework/components/chui_inputs/chui_combo_box/styles.css +23 -21
- package/framework/components/chui_inputs/chui_date/date.js +161 -127
- package/framework/components/chui_inputs/chui_date/styles.css +20 -19
- package/framework/components/chui_inputs/chui_date_range/date_range.js +61 -50
- package/framework/components/chui_inputs/chui_date_range/styles.css +3 -3
- package/framework/components/chui_inputs/chui_datetime/datetime.js +47 -39
- package/framework/components/chui_inputs/chui_datetime/styles.css +3 -3
- package/framework/components/chui_inputs/chui_email/email.js +48 -36
- package/framework/components/chui_inputs/chui_email/styles.css +11 -11
- package/framework/components/chui_inputs/chui_file/file.js +71 -44
- package/framework/components/chui_inputs/chui_file/styles.css +23 -19
- package/framework/components/chui_inputs/chui_hotkey/hotkey.js +101 -76
- package/framework/components/chui_inputs/chui_hotkey/styles.css +6 -6
- package/framework/components/chui_inputs/chui_masked/masked.js +83 -72
- package/framework/components/chui_inputs/chui_masked/styles.css +5 -5
- package/framework/components/chui_inputs/chui_mention/mention.js +87 -40
- package/framework/components/chui_inputs/chui_mention/styles.css +14 -8
- package/framework/components/chui_inputs/chui_multi_combo_box/multi_combo_box.js +242 -212
- package/framework/components/chui_inputs/chui_multi_combo_box/styles.css +23 -15
- package/framework/components/chui_inputs/chui_number/number.js +84 -70
- package/framework/components/chui_inputs/chui_number/styles.css +13 -13
- package/framework/components/chui_inputs/chui_otp/otp.js +71 -62
- package/framework/components/chui_inputs/chui_otp/styles.css +5 -5
- package/framework/components/chui_inputs/chui_password/password.js +72 -56
- package/framework/components/chui_inputs/chui_password/styles.css +15 -15
- package/framework/components/chui_inputs/chui_radio_button/radio_button.js +37 -25
- package/framework/components/chui_inputs/chui_radio_button/styles.css +22 -22
- package/framework/components/chui_inputs/chui_rating/rating.js +54 -50
- package/framework/components/chui_inputs/chui_rating/styles.css +3 -3
- package/framework/components/chui_inputs/chui_select_box/select_box.js +132 -108
- package/framework/components/chui_inputs/chui_select_box/styles.css +21 -21
- package/framework/components/chui_inputs/chui_slider/slider.js +41 -38
- package/framework/components/chui_inputs/chui_slider/styles.css +7 -5
- package/framework/components/chui_inputs/chui_text/styles.css +14 -14
- package/framework/components/chui_inputs/chui_text/text.js +69 -52
- package/framework/components/chui_inputs/chui_text_area/styles.css +14 -14
- package/framework/components/chui_inputs/chui_text_area/text_area.js +59 -36
- package/framework/components/chui_inputs/chui_time/styles.css +18 -20
- package/framework/components/chui_inputs/chui_time/time.js +71 -49
- package/framework/components/chui_inputs/chui_toggle/styles.css +13 -6
- package/framework/components/chui_inputs/chui_toggle/toggle.js +27 -27
- package/framework/components/chui_json_editor/json_editor.js +137 -127
- package/framework/components/chui_json_editor/styles.css +5 -5
- package/framework/components/chui_json_viewer/json_viewer.js +133 -114
- package/framework/components/chui_json_viewer/styles.css +9 -8
- package/framework/components/chui_kanban/kanban.js +78 -60
- package/framework/components/chui_kanban/styles.css +8 -4
- package/framework/components/chui_kbd/kbd.js +12 -10
- package/framework/components/chui_kbd/styles.css +1 -1
- package/framework/components/chui_label/label.js +31 -29
- package/framework/components/chui_label/styles.css +3 -3
- package/framework/components/chui_link/link.js +36 -29
- package/framework/components/chui_link/styles.css +11 -4
- package/framework/components/chui_list/list.js +42 -38
- package/framework/components/chui_list/styles.css +4 -3
- package/framework/components/chui_markdown_editor/markdown_editor.js +125 -109
- package/framework/components/chui_markdown_editor/styles.css +5 -5
- package/framework/components/chui_media/audio.js +457 -364
- package/framework/components/chui_media/audio_styles.css +74 -37
- package/framework/components/chui_media/image.js +99 -64
- package/framework/components/chui_media/image_compare.js +86 -55
- package/framework/components/chui_media/image_compare_styles.css +5 -5
- package/framework/components/chui_media/image_styles.css +4 -4
- package/framework/components/chui_media/video.js +264 -171
- package/framework/components/chui_media/video_styles.css +57 -24
- package/framework/components/chui_menu_bar/menu_bar.js +67 -65
- package/framework/components/chui_menu_bar/styles.css +14 -12
- 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 +81 -56
- package/framework/components/chui_modal/styles.css +3 -2
- package/framework/components/chui_notification/notification.js +107 -69
- package/framework/components/chui_notification/notification_download.js +28 -77
- package/framework/components/chui_notification/notification_download_card.js +115 -0
- package/framework/components/chui_notification/notification_download_progress.css +5 -4
- package/framework/components/chui_notification/notification_download_progress.js +73 -105
- package/framework/components/chui_notification/notification_update.js +44 -37
- package/framework/components/chui_notification/status_plate.css +3 -3
- package/framework/components/chui_notification/status_plate.js +2 -2
- package/framework/components/chui_notification/styles.css +6 -1
- package/framework/components/chui_notification/styles_download.css +2 -2
- package/framework/components/chui_notification/styles_update.css +3 -3
- package/framework/components/chui_org_chart/org_chart.js +101 -62
- package/framework/components/chui_org_chart/styles.css +2 -2
- package/framework/components/chui_pagination/pagination.js +65 -30
- package/framework/components/chui_pagination/styles.css +1 -1
- package/framework/components/chui_paragraph/paragraph.js +2 -2
- package/framework/components/chui_paragraph/styles.css +3 -3
- package/framework/components/chui_popover/popover.js +77 -50
- package/framework/components/chui_popover/styles.css +1 -1
- package/framework/components/chui_popups/popups.js +210 -181
- package/framework/components/chui_popups/styles.css +7 -7
- package/framework/components/chui_print_button/print_button.js +48 -33
- package/framework/components/chui_progress_bar/progress_bar.js +49 -19
- package/framework/components/chui_progress_bar/styles.css +6 -6
- package/framework/components/chui_progress_ring/progress_ring.js +32 -30
- package/framework/components/chui_progress_ring/styles.css +1 -1
- package/framework/components/chui_qrcode/qrcode.js +85 -41
- package/framework/components/chui_query_builder/query_builder.js +263 -170
- package/framework/components/chui_query_builder/styles.css +4 -4
- package/framework/components/chui_radio_group/radio_group.js +36 -26
- package/framework/components/chui_radio_group/styles.css +2 -2
- package/framework/components/chui_result/result.js +41 -31
- package/framework/components/chui_result/styles.css +3 -3
- package/framework/components/chui_scheduler/scheduler.js +126 -104
- package/framework/components/chui_scheduler/styles.css +11 -7
- 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 +90 -27
- package/framework/components/chui_segmented_control/styles.css +32 -6
- package/framework/components/chui_settings/settings.js +160 -87
- package/framework/components/chui_settings/styles.css +4 -4
- package/framework/components/chui_shortcut_help/shortcut_help.js +82 -61
- package/framework/components/chui_sidebar_nav/sidebar_nav.js +132 -53
- package/framework/components/chui_sidebar_nav/styles.css +9 -3
- package/framework/components/chui_skeleton/skeleton.js +29 -24
- package/framework/components/chui_slideshow/slideshow.js +122 -103
- package/framework/components/chui_slideshow/styles.css +22 -16
- package/framework/components/chui_spinner/spinner.js +17 -17
- package/framework/components/chui_spinner/styles.css +0 -1
- package/framework/components/chui_split_button/split_button.js +53 -30
- package/framework/components/chui_split_button/styles.css +22 -6
- package/framework/components/chui_splitter/splitter.js +46 -39
- package/framework/components/chui_splitter/styles.css +2 -2
- package/framework/components/chui_statistic/statistic.js +111 -84
- package/framework/components/chui_status_bar/status_bar.js +11 -9
- package/framework/components/chui_status_bar/styles.css +2 -2
- package/framework/components/chui_stepper/stepper.js +230 -54
- package/framework/components/chui_stepper/styles.css +79 -14
- package/framework/components/chui_sticky_bar/sticky_bar.js +53 -17
- package/framework/components/chui_sticky_bar/styles.css +11 -7
- package/framework/components/chui_tabs/chui_tabs.js +100 -46
- package/framework/components/chui_tabs/styles.css +43 -15
- package/framework/components/chui_text_editor/styles.css +24 -23
- package/framework/components/chui_text_editor/text_editor.js +103 -79
- package/framework/components/chui_text_editor/text_editor_panel.js +428 -367
- package/framework/components/chui_theme_toggle/theme_toggle.js +46 -25
- package/framework/components/chui_timeline/styles.css +3 -1
- package/framework/components/chui_timeline/timeline.js +14 -12
- package/framework/components/chui_toast_queue/styles.css +11 -11
- package/framework/components/chui_toast_queue/toast_queue.js +82 -73
- package/framework/components/chui_toolbar/styles.css +2 -2
- package/framework/components/chui_toolbar/toolbar.js +20 -13
- package/framework/components/chui_tooltip/styles.css +2 -2
- package/framework/components/chui_tooltip/tooltip.js +40 -27
- package/framework/components/chui_tour/styles.css +12 -7
- package/framework/components/chui_tour/tour.js +132 -98
- package/framework/components/chui_transfer/styles.css +8 -7
- package/framework/components/chui_transfer/transfer.js +143 -123
- package/framework/components/chui_tree_table/styles.css +4 -4
- package/framework/components/chui_tree_table/tree_table.js +88 -74
- package/framework/components/chui_tree_view/styles.css +14 -10
- package/framework/components/chui_tree_view/tree_view.js +298 -253
- 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 +41 -33
- package/framework/components/chui_webview/styles.css +2 -2
- package/framework/components/chui_webview/webview.js +64 -52
- 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 +153 -123
- package/framework/modules/chui_animations/animations.js +105 -43
- package/framework/modules/chui_animations/motion.js +120 -0
- package/framework/modules/chui_animations/styles.css +37 -4
- package/framework/modules/chui_bridge/preload.js +36 -25
- package/framework/modules/chui_functions.js +69 -63
- package/framework/modules/chui_highlight/chui_highlight.js +25 -22
- package/framework/modules/chui_highlight/styles.css +13 -10
- package/framework/modules/chui_i18n/chui_i18n.js +12 -10
- package/framework/modules/chui_i18n/locales/a11y.js +5 -5
- package/framework/modules/chui_i18n/locales/app_layout.js +5 -5
- package/framework/modules/chui_i18n/locales/calendar.js +3 -3
- package/framework/modules/chui_i18n/locales/common.js +3 -3
- package/framework/modules/chui_i18n/locales/data.js +36 -26
- package/framework/modules/chui_i18n/locales/editors.js +40 -13
- package/framework/modules/chui_i18n/locales/feedback.js +65 -3
- package/framework/modules/chui_i18n/locales/forms.js +8 -8
- package/framework/modules/chui_i18n/locales/graphics.js +8 -8
- package/framework/modules/chui_i18n/locales/media.js +4 -4
- package/framework/modules/chui_i18n/locales/navigation.js +12 -8
- package/framework/modules/chui_i18n/locales/validate.js +3 -3
- package/framework/modules/chui_json/chui_json.js +11 -11
- package/framework/modules/chui_layout/chui_layout.js +12 -10
- package/framework/modules/chui_logger/chui_logger.js +47 -28
- package/framework/modules/chui_page/page.js +39 -33
- package/framework/modules/chui_palette/palette.js +61 -0
- package/framework/modules/chui_qr/chui_qr.js +179 -88
- package/framework/modules/chui_scrollbar/chui_scrollbar.js +79 -68
- package/framework/modules/chui_scrollbar/styles.css +4 -2
- package/framework/modules/chui_services/chui_channels.js +27 -13
- package/framework/modules/chui_services/chui_services.js +47 -39
- package/framework/modules/chui_services/desktop.js +108 -101
- package/framework/modules/chui_sortable/chui_sortable.js +33 -28
- package/framework/modules/chui_validate/chui_validate.js +10 -7
- package/framework/tray_bar/chui_menu_items.js +136 -46
- package/index.d.ts +5046 -0
- package/index.js +199 -1254
- package/package.json +81 -68
- package/framework/components/telegram_bot/chui_telegram_bot.js +0 -184
|
@@ -1,42 +1,43 @@
|
|
|
1
|
-
const {Animation} = require(
|
|
2
|
-
const {
|
|
3
|
-
const {
|
|
1
|
+
const { Animation } = require("../../modules/chui_animations/animations");
|
|
2
|
+
const { reducedMotion, tokenTime } = require("../../modules/chui_animations/motion");
|
|
3
|
+
const { Icon, Icons } = require("../../components/chui_icons/icons");
|
|
4
|
+
const { Button } = require("../../components/chui_button/button");
|
|
4
5
|
// НАСТРОЙКИ
|
|
5
|
-
const {Dialog} = require("../../components/chui_modal/modal");
|
|
6
|
-
const {Label} = require("../../components/chui_label/label");
|
|
7
|
-
const {ContentBlock} = require("../../components/chui_content_block/content_block");
|
|
8
|
-
const {Popup} = require("../../components/chui_popups/popups");
|
|
9
|
-
const {UpdateNotification} = require("../../components/chui_notification/notification_update");
|
|
10
|
-
const {ipcRenderer} = require("electron");
|
|
11
|
-
const
|
|
12
|
-
const {DownloadNotification} = require("../../components/chui_notification/notification_download");
|
|
13
|
-
const {Log} = require("../../../framework/modules/chui_logger/chui_logger");
|
|
14
|
-
const {MenuKeyboard, makeKeyboardButton} = require(
|
|
15
|
-
const {LAYOUT_ROLE, LayoutRefs, registerLayout, requireLayoutNode} = require(
|
|
16
|
-
const {BRIDGE_CHANNELS} = require(
|
|
17
|
-
const {OverlayScrollbars} = require(
|
|
18
|
-
const {MenuBar} = require(
|
|
19
|
-
const {Avatar} = require(
|
|
20
|
-
const {EmptyState} = require(
|
|
21
|
-
const {I18n} = require(
|
|
6
|
+
const { Dialog } = require("../../components/chui_modal/modal");
|
|
7
|
+
const { Label } = require("../../components/chui_label/label");
|
|
8
|
+
const { ContentBlock } = require("../../components/chui_content_block/content_block");
|
|
9
|
+
const { Popup } = require("../../components/chui_popups/popups");
|
|
10
|
+
const { UpdateNotification } = require("../../components/chui_notification/notification_update");
|
|
11
|
+
const { ipcRenderer } = require("electron");
|
|
12
|
+
const chuiFunctions = require("../../modules/chui_functions");
|
|
13
|
+
const { DownloadNotification } = require("../../components/chui_notification/notification_download");
|
|
14
|
+
const { Log } = require("../../../framework/modules/chui_logger/chui_logger");
|
|
15
|
+
const { MenuKeyboard, makeKeyboardButton } = require("../../modules/chui_a11y/chui_a11y");
|
|
16
|
+
const { LAYOUT_ROLE, LayoutRefs, registerLayout, requireLayoutNode } = require("../../modules/chui_layout/chui_layout");
|
|
17
|
+
const { BRIDGE_CHANNELS } = require("../../modules/chui_services/chui_channels");
|
|
18
|
+
const { OverlayScrollbars } = require("../../modules/chui_scrollbar/chui_scrollbar");
|
|
19
|
+
const { MenuBar } = require("../../components/chui_menu_bar/menu_bar");
|
|
20
|
+
const { Avatar } = require("../../components/chui_avatar/avatar");
|
|
21
|
+
const { EmptyState } = require("../../components/chui_empty_state/empty_state");
|
|
22
|
+
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,
|
|
35
36
|
title: page.getTitle(),
|
|
36
|
-
page: page
|
|
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,24 +86,24 @@ 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
|
-
center.innerHTML =
|
|
92
|
+
center.innerHTML = "";
|
|
92
93
|
// Содержимое собирает сама страница (`Page#render`), оболочка в него не подмешивает
|
|
93
94
|
// ничего: узел `page_title` (был здесь) дублировал заголовок, который страница
|
|
94
95
|
// рисует сама, а `Page#render` отдаёт один и тот же узел — повторный переход на тот
|
|
95
96
|
// же экземпляр страницы (кнопка «Назад» из `Page#setBackButton`) добавлял второй
|
|
96
97
|
// заголовок. Место названия — шапка (`setPageName`).
|
|
97
98
|
center.appendChild(page.render());
|
|
98
|
-
const _page = center.querySelector(
|
|
99
|
+
const _page = center.querySelector("page");
|
|
99
100
|
new Animation(_page).fadeIn();
|
|
100
101
|
// Стирания `center.removeAttribute("style")` здесь больше нет: инлайновых
|
|
101
102
|
// стилей на `center` не ставит ни одна часть оболочки, а стирать чужое
|
|
102
103
|
// (если появится) — это ловушка, на которой уже терялись `display: flex`
|
|
103
104
|
applyPageMenuBar(requireLayoutNode(LAYOUT_ROLE.HEADER), page.getMenuBar());
|
|
104
105
|
|
|
105
|
-
this.route(page)
|
|
106
|
+
this.route(page);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -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
|
|
|
@@ -161,23 +162,26 @@ function makeHeaderButtonBox(options = { title: undefined, icon: undefined, reve
|
|
|
161
162
|
* подобраны под вид набора; цвет наследуется от кнопки через `currentColor`.
|
|
162
163
|
*/
|
|
163
164
|
const WINDOW_ICONS = {
|
|
164
|
-
minimize:
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
minimize:
|
|
166
|
+
'<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.5 7.5 L10 12.5 L15.5 7.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
167
|
+
maximize:
|
|
168
|
+
'<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.5 12.5 L10 7.5 L15.5 12.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
169
|
+
restore:
|
|
170
|
+
'<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 5 L15 10 L10 15 L5 10 Z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
167
171
|
close: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M5 5 L15 15 M15 5 L5 15" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>',
|
|
168
172
|
};
|
|
169
173
|
|
|
170
174
|
class WindowControls {
|
|
171
175
|
// Управление кнопками
|
|
172
|
-
#box = document.createElement("wc_box")
|
|
173
|
-
#close = document.createElement("wc_close")
|
|
174
|
-
#maximize = document.createElement("wc_maximize")
|
|
175
|
-
#minimize = document.createElement("wc_minimize")
|
|
176
|
+
#box = document.createElement("wc_box");
|
|
177
|
+
#close = document.createElement("wc_close");
|
|
178
|
+
#maximize = document.createElement("wc_maximize");
|
|
179
|
+
#minimize = document.createElement("wc_minimize");
|
|
176
180
|
// Подписка на нересайзящееся окно: set() вызывается и из конструктора Header, и из
|
|
177
181
|
// addWC(), поэтому в set() слушатель ipcRenderer накапливался.
|
|
178
|
-
#
|
|
182
|
+
#resizableFalseEvent = () => this.#maximize.remove();
|
|
179
183
|
// Номер запроса состояния гасит ответы, пришедшие не в том порядке
|
|
180
|
-
#
|
|
184
|
+
#syncRequest = 0;
|
|
181
185
|
constructor() {
|
|
182
186
|
// Скрипт окна может быть preload'ом и выполняться до документа: здесь документ уже настоящий,
|
|
183
187
|
// поэтому `dir`/`lang` (и зеркалирование логических свойств CSS) встают гарантированно
|
|
@@ -188,15 +192,15 @@ class WindowControls {
|
|
|
188
192
|
this.#setButton(this.#minimize, I18n.t("appLayout.window.minimize"), "minimize");
|
|
189
193
|
// «Развернуть» — шеврон вверх, зеркало «свернуть»: пара читается как одна ось
|
|
190
194
|
this.#setButton(this.#maximize, I18n.t("appLayout.window.maximize"), "maximize");
|
|
191
|
-
this.#close.addEventListener("click", () => this.#windowAction("close"))
|
|
192
|
-
this.#minimize.addEventListener("click", () => this.#windowAction("minimize"))
|
|
195
|
+
this.#close.addEventListener("click", () => this.#windowAction("close"));
|
|
196
|
+
this.#minimize.addEventListener("click", () => this.#windowAction("minimize"));
|
|
193
197
|
// Разворот и восстановление — одно действие: решение принимает окно, а не кнопка
|
|
194
|
-
this.#maximize.addEventListener("click", () => this.#windowAction("toggle-maximize"))
|
|
198
|
+
this.#maximize.addEventListener("click", () => this.#windowAction("toggle-maximize"));
|
|
195
199
|
// Разворот приходит не только от нашей кнопки (двойной клик по полосе, меню окна),
|
|
196
200
|
// поэтому состояние читаем по факту изменения размера окна
|
|
197
|
-
window.addEventListener("resize", this.#
|
|
198
|
-
this.#syncMaximized()
|
|
199
|
-
ipcRenderer.on("chui_resizable_false", this.#
|
|
201
|
+
window.addEventListener("resize", this.#syncMaximizedEvent);
|
|
202
|
+
this.#syncMaximized();
|
|
203
|
+
ipcRenderer.on("chui_resizable_false", this.#resizableFalseEvent);
|
|
200
204
|
}
|
|
201
205
|
/** Кнопка окна — значок с именем для скринридера, фокусом и активацией с клавиатуры.
|
|
202
206
|
* Значок рисуется линиями (`WINDOW_ICONS`), размер задаёт CSS (см. global_style.css) */
|
|
@@ -233,9 +237,9 @@ class WindowControls {
|
|
|
233
237
|
// Состояние приходит асинхронно, поэтому ответ устаревшего запроса отбрасывается по номеру:
|
|
234
238
|
// иначе быстрая пара «развернуть/восстановить» могла бы применить ответ в обратном порядке
|
|
235
239
|
#syncMaximized() {
|
|
236
|
-
const request = ++this.#
|
|
240
|
+
const request = ++this.#syncRequest;
|
|
237
241
|
this.#windowState().then((state) => {
|
|
238
|
-
if (request !== this.#
|
|
242
|
+
if (request !== this.#syncRequest) return;
|
|
239
243
|
const maximized = state.isMaximized === true;
|
|
240
244
|
const label = maximized ? I18n.t("appLayout.window.restore") : I18n.t("appLayout.window.maximize");
|
|
241
245
|
this.#maximize.classList.toggle("wc_maximized", maximized);
|
|
@@ -244,39 +248,39 @@ class WindowControls {
|
|
|
244
248
|
this.#maximize.setAttribute("aria-label", label);
|
|
245
249
|
});
|
|
246
250
|
}
|
|
247
|
-
#
|
|
248
|
-
set(
|
|
249
|
-
if (
|
|
250
|
-
this.#box.appendChild(this.#close)
|
|
251
|
-
this.#box.appendChild(this.#maximize)
|
|
252
|
-
this.#box.appendChild(this.#minimize)
|
|
251
|
+
#syncMaximizedEvent = () => this.#syncMaximized();
|
|
252
|
+
set(isEnabled = Boolean()) {
|
|
253
|
+
if (isEnabled) {
|
|
254
|
+
this.#box.appendChild(this.#close);
|
|
255
|
+
this.#box.appendChild(this.#maximize);
|
|
256
|
+
this.#box.appendChild(this.#minimize);
|
|
253
257
|
} else {
|
|
254
|
-
this.#box.appendChild(this.#minimize)
|
|
255
|
-
this.#box.appendChild(this.#maximize)
|
|
256
|
-
this.#box.appendChild(this.#close)
|
|
258
|
+
this.#box.appendChild(this.#minimize);
|
|
259
|
+
this.#box.appendChild(this.#maximize);
|
|
260
|
+
this.#box.appendChild(this.#close);
|
|
257
261
|
}
|
|
258
262
|
return this.#box;
|
|
259
263
|
}
|
|
260
264
|
}
|
|
261
265
|
|
|
262
266
|
class Header {
|
|
263
|
-
#
|
|
267
|
+
#wcBox = new WindowControls();
|
|
264
268
|
#header = document.createElement("header");
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
269
|
+
#headerMain = document.createElement("header_main");
|
|
270
|
+
#headerLeftBox = document.createElement("header_left_box");
|
|
271
|
+
#headerRightBox = document.createElement("header_right_box");
|
|
268
272
|
// Высота полосы измеряется, а не предполагается — см. #observeHeight
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
273
|
+
#heightObserver = undefined;
|
|
274
|
+
#heightFrame = 0;
|
|
275
|
+
#publishedHeight = 0;
|
|
272
276
|
constructor(layout) {
|
|
273
277
|
layout.set(LAYOUT_ROLE.HEADER, this.#header);
|
|
274
278
|
this.#header.id = "header";
|
|
275
|
-
this.#
|
|
276
|
-
this.#
|
|
277
|
-
this.#
|
|
278
|
-
this.#
|
|
279
|
-
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);
|
|
280
284
|
this.#observeHeight();
|
|
281
285
|
}
|
|
282
286
|
/*
|
|
@@ -289,8 +293,8 @@ class Header {
|
|
|
289
293
|
* Теперь высота измеряется и публикуется, поэтому токен верен при любой полосе.
|
|
290
294
|
*/
|
|
291
295
|
#observeHeight() {
|
|
292
|
-
this.#
|
|
293
|
-
this.#
|
|
296
|
+
this.#heightObserver = new ResizeObserver(() => this.#publishHeight());
|
|
297
|
+
this.#heightObserver.observe(this.#header);
|
|
294
298
|
this.#publishHeight();
|
|
295
299
|
}
|
|
296
300
|
/*
|
|
@@ -302,40 +306,41 @@ class Header {
|
|
|
302
306
|
* записи без проверки крутят пересчёт на ровном месте.
|
|
303
307
|
*/
|
|
304
308
|
#publishHeight() {
|
|
305
|
-
if (this.#
|
|
306
|
-
this.#
|
|
307
|
-
this.#
|
|
309
|
+
if (this.#heightFrame !== 0) return;
|
|
310
|
+
this.#heightFrame = requestAnimationFrame(() => {
|
|
311
|
+
this.#heightFrame = 0;
|
|
308
312
|
// offsetHeight включает черту снизу: она принадлежит полосе, от неё и считается отступ
|
|
309
313
|
const height = this.#header.offsetHeight;
|
|
310
|
-
if (height === 0 || height === this.#
|
|
311
|
-
this.#
|
|
314
|
+
if (height === 0 || height === this.#publishedHeight) return;
|
|
315
|
+
this.#publishedHeight = height;
|
|
312
316
|
document.documentElement.style.setProperty("--header_height", `${height}px`);
|
|
313
317
|
});
|
|
314
318
|
}
|
|
315
319
|
addWC(boolean = Boolean()) {
|
|
316
|
-
this.#
|
|
320
|
+
this.#headerMain.insertBefore(this.#wcBox.set(boolean), this.#headerMain.firstChild);
|
|
317
321
|
}
|
|
318
322
|
addToLeftBeforeTitle(...components) {
|
|
319
|
-
for (
|
|
323
|
+
for (const component of components) this.#headerLeftBox.insertBefore(component, this.#headerLeftBox.firstChild);
|
|
320
324
|
}
|
|
321
325
|
/** Прежнее имя с опечаткой (`Tittle`): оставлено синонимом, чтобы не ломать чужие вызовы */
|
|
322
326
|
addToLeftBeforeTittle(...components) {
|
|
323
327
|
this.addToLeftBeforeTitle(...components);
|
|
324
328
|
}
|
|
325
329
|
addToLeft(...components) {
|
|
326
|
-
for (
|
|
327
|
-
this.#
|
|
330
|
+
for (const component of components) {
|
|
331
|
+
this.#headerLeftBox.appendChild(component);
|
|
328
332
|
}
|
|
329
333
|
}
|
|
330
334
|
addToRight(...components) {
|
|
331
|
-
for (
|
|
335
|
+
for (const component of components)
|
|
336
|
+
this.#headerRightBox.insertBefore(component, this.#headerRightBox.firstChild);
|
|
332
337
|
}
|
|
333
338
|
removeToRight(...components) {
|
|
334
|
-
for (
|
|
339
|
+
for (const component of components) {
|
|
335
340
|
// У меню профиля панель лежит в body, а не в шапке (см. UserProfile),
|
|
336
341
|
// поэтому снятие кнопки обязано убирать и панель: иначе в body копились бы невидимые меню
|
|
337
342
|
UserProfile.destroy(component);
|
|
338
|
-
this.#
|
|
343
|
+
this.#headerRightBox.removeChild(component);
|
|
339
344
|
}
|
|
340
345
|
}
|
|
341
346
|
set() {
|
|
@@ -344,7 +349,7 @@ class Header {
|
|
|
344
349
|
}
|
|
345
350
|
|
|
346
351
|
class Center {
|
|
347
|
-
#center = document.createElement(
|
|
352
|
+
#center = document.createElement("center");
|
|
348
353
|
constructor(layout) {
|
|
349
354
|
this.#center.id = "center";
|
|
350
355
|
layout.set(LAYOUT_ROLE.CENTER, this.#center);
|
|
@@ -356,30 +361,32 @@ class Center {
|
|
|
356
361
|
|
|
357
362
|
class AppMenu {
|
|
358
363
|
#routeList = [];
|
|
359
|
-
#appMenu = document.createElement(
|
|
364
|
+
#appMenu = document.createElement("app_menu");
|
|
360
365
|
#appMenuWidth = 400;
|
|
361
366
|
#appMenuOffset = 25;
|
|
362
|
-
get #appMenuSlide() {
|
|
363
|
-
|
|
364
|
-
|
|
367
|
+
get #appMenuSlide() {
|
|
368
|
+
return this.#appMenuWidth + this.#appMenuOffset;
|
|
369
|
+
}
|
|
370
|
+
#routeViews = document.createElement("route_views");
|
|
371
|
+
#appMenuButton = document.createElement("app_menu_button");
|
|
365
372
|
// Шапка панели: плитка со значком и заголовок — панель читается выдвижным ящиком
|
|
366
|
-
#
|
|
367
|
-
#
|
|
368
|
-
#
|
|
373
|
+
#appMenuHeader = document.createElement("app_menu_header");
|
|
374
|
+
#appMenuHeaderIcon = document.createElement("app_menu_header_icon");
|
|
375
|
+
#appMenuHeaderTitle = document.createElement("app_menu_header_title");
|
|
369
376
|
// Блок поиска: своя поверхность со значком, а не одинокий ввод на фоне панели
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
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");
|
|
374
381
|
// Подсказка, когда фильтр не нашёл ни одного пункта
|
|
375
|
-
#
|
|
382
|
+
#appMenuEmpty = document.createElement("app_menu_empty");
|
|
376
383
|
// ===
|
|
377
|
-
#
|
|
378
|
-
#
|
|
384
|
+
#autoClose = false;
|
|
385
|
+
#searchEnabled = false;
|
|
379
386
|
#routeKeyboard = undefined;
|
|
380
387
|
#header = undefined;
|
|
381
388
|
#center = undefined;
|
|
382
|
-
#
|
|
389
|
+
#pageName = document.createElement("page_name");
|
|
383
390
|
constructor(header, center, layout) {
|
|
384
391
|
this.#header = header;
|
|
385
392
|
this.#center = center;
|
|
@@ -389,9 +396,9 @@ class AppMenu {
|
|
|
389
396
|
this.#appMenu.style.width = `${this.#appMenuWidth}px`;
|
|
390
397
|
this.#appMenu.style.left = `calc(-${this.#appMenuSlide}px)`;
|
|
391
398
|
//
|
|
392
|
-
this.#appMenuButton.id = "appMenuButton"
|
|
399
|
+
this.#appMenuButton.id = "appMenuButton";
|
|
393
400
|
// Список пунктов — тот узел, которым управляет кнопка: `aria-controls` ссылается на него
|
|
394
|
-
this.#routeViews.id = "chui_app_menu"
|
|
401
|
+
this.#routeViews.id = "chui_app_menu";
|
|
395
402
|
// Значок — украшение: без подписи имя кнопки складывалось из лигатуры («menu»)
|
|
396
403
|
this.#appMenuButton.setAttribute("aria-label", I18n.t("appLayout.menu"));
|
|
397
404
|
this.#appMenuButton.setAttribute("title", I18n.t("appLayout.menu"));
|
|
@@ -399,37 +406,37 @@ class AppMenu {
|
|
|
399
406
|
this.#appMenuButton.addEventListener("click", () => {
|
|
400
407
|
if (this.#appMenuButton.classList.contains("app_menu_button_active")) this.#close();
|
|
401
408
|
else this.#open();
|
|
402
|
-
})
|
|
403
|
-
header.set().addEventListener(
|
|
409
|
+
});
|
|
410
|
+
header.set().addEventListener("click", (e) => {
|
|
404
411
|
if (e.target !== this.#appMenuButton) this.#close();
|
|
405
|
-
})
|
|
406
|
-
center.addEventListener(
|
|
412
|
+
});
|
|
413
|
+
center.addEventListener("click", (e) => {
|
|
407
414
|
if (!this.#appMenu.contains(e.target)) {
|
|
408
415
|
if (this.#appMenu.style.transform === `translateX(${this.#appMenuSlide}px)`) this.#close();
|
|
409
416
|
}
|
|
410
|
-
})
|
|
411
|
-
this.#
|
|
412
|
-
layout.set(LAYOUT_ROLE.PAGE_NAME, this.#
|
|
413
|
-
header.addToLeft(this.#appMenuButton, this.#
|
|
414
|
-
this.#
|
|
417
|
+
});
|
|
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());
|
|
415
422
|
// Плитка и значок в ней — украшение: имя панели уже есть в заголовке
|
|
416
|
-
this.#
|
|
417
|
-
this.#
|
|
423
|
+
this.#appMenuHeaderIcon.setAttribute("aria-hidden", "true");
|
|
424
|
+
this.#appMenuHeaderTitle.innerText = I18n.t("appLayout.menu");
|
|
418
425
|
// Список пунктов назван головкой панели: скринридер читает «Меню», а не безымянный список
|
|
419
|
-
this.#
|
|
420
|
-
this.#routeViews.setAttribute("aria-labelledby", this.#
|
|
421
|
-
this.#
|
|
422
|
-
this.#
|
|
423
|
-
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");
|
|
424
431
|
// Подсказка лежит внутри списка, но пунктом не является: клавиатура меню её не видит (itemSelector "route")
|
|
425
|
-
this.#routeViews.appendChild(this.#
|
|
426
|
-
this.#appMenu.appendChild(this.#
|
|
427
|
-
this.#appMenu.appendChild(this.#routeViews)
|
|
432
|
+
this.#routeViews.appendChild(this.#appMenuEmpty);
|
|
433
|
+
this.#appMenu.appendChild(this.#appMenuHeader);
|
|
434
|
+
this.#appMenu.appendChild(this.#routeViews);
|
|
428
435
|
// Клавиатура и aria меню маршрутов: стрелки по пунктам, Escape закрывает и возвращает фокус на кнопку
|
|
429
436
|
this.#routeKeyboard = new MenuKeyboard(this.#routeViews, {
|
|
430
437
|
itemSelector: "route",
|
|
431
438
|
onEscape: () => this.#close(),
|
|
432
|
-
trigger: this.#appMenuButton
|
|
439
|
+
trigger: this.#appMenuButton,
|
|
433
440
|
});
|
|
434
441
|
makeKeyboardButton(this.#appMenuButton);
|
|
435
442
|
// Закрытая панель объявляется закрытой сразу: иначе у кнопки не было бы aria-expanded,
|
|
@@ -464,20 +471,20 @@ class AppMenu {
|
|
|
464
471
|
this.#routeKeyboard.setExpanded(false);
|
|
465
472
|
}
|
|
466
473
|
enableSearchInput() {
|
|
467
|
-
if (this.#
|
|
468
|
-
this.#
|
|
469
|
-
this.#
|
|
470
|
-
this.#
|
|
471
|
-
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"));
|
|
472
479
|
// Подсветку поля даёт :focus-within на поверхности поиска — инлайновых стилей больше нет
|
|
473
|
-
this.#
|
|
474
|
-
this.#filterRoutes(event.target.value)
|
|
475
|
-
})
|
|
476
|
-
this.#
|
|
477
|
-
this.#
|
|
478
|
-
this.#
|
|
479
|
-
this.#
|
|
480
|
-
this.#appMenu.insertBefore(this.#
|
|
480
|
+
this.#appMenuSearchInput.addEventListener("input", (event) => {
|
|
481
|
+
this.#filterRoutes(event.target.value);
|
|
482
|
+
});
|
|
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);
|
|
481
488
|
}
|
|
482
489
|
// Пункты списка: подсказка о пустом результате лежит там же, поэтому фильтруем по тегу
|
|
483
490
|
#routes() {
|
|
@@ -493,15 +500,15 @@ class AppMenu {
|
|
|
493
500
|
item.style.display = matches ? "" : "none";
|
|
494
501
|
if (matches) visible++;
|
|
495
502
|
}
|
|
496
|
-
this.#
|
|
503
|
+
this.#appMenuEmpty.classList.toggle("app_menu_empty_visible", visible === 0);
|
|
497
504
|
}
|
|
498
505
|
go(page) {
|
|
499
|
-
|
|
506
|
+
const center = this.#center;
|
|
500
507
|
//
|
|
501
508
|
setPageName(page.getTitle());
|
|
502
|
-
center.innerHTML =
|
|
509
|
+
center.innerHTML = "";
|
|
503
510
|
center.appendChild(page.render());
|
|
504
|
-
const _page = center.querySelector(
|
|
511
|
+
const _page = center.querySelector("page");
|
|
505
512
|
new Animation(_page).fadeIn();
|
|
506
513
|
// Стирания `center.removeAttribute("style")` здесь больше нет: инлайновых
|
|
507
514
|
// стилей на `center` не ставит ни одна часть оболочки, а стирать чужое
|
|
@@ -510,33 +517,33 @@ class AppMenu {
|
|
|
510
517
|
}
|
|
511
518
|
setRouteTest(page) {
|
|
512
519
|
this.#routeList.push(page);
|
|
513
|
-
|
|
520
|
+
const test = this.#routeList.filter((route) => route.getTitle().includes(page.getTitle()));
|
|
514
521
|
if (test.length === 1) {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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();
|
|
520
527
|
if (page.getMain()) {
|
|
521
528
|
this.go(page);
|
|
522
|
-
|
|
529
|
+
buttonRoute.classList.add("route_active");
|
|
523
530
|
}
|
|
524
|
-
|
|
525
|
-
const eventAwesome = new CustomEvent("route_event_"+page.constructor.name, {
|
|
531
|
+
buttonRoute.addEventListener("click", () => {
|
|
532
|
+
const eventAwesome = new CustomEvent("route_event_" + page.constructor.name, {
|
|
526
533
|
bubbles: true,
|
|
527
534
|
detail: {
|
|
528
535
|
class: page.constructor.name,
|
|
529
536
|
title: page.getTitle(),
|
|
530
|
-
page: page
|
|
537
|
+
page: page,
|
|
531
538
|
},
|
|
532
539
|
});
|
|
533
|
-
document.dispatchEvent(eventAwesome)
|
|
540
|
+
document.dispatchEvent(eventAwesome);
|
|
534
541
|
|
|
535
|
-
if (!
|
|
536
|
-
for (const act of this.#routes()) act.classList.remove(
|
|
542
|
+
if (!buttonRoute.classList.contains("route_active")) {
|
|
543
|
+
for (const act of this.#routes()) act.classList.remove("route_active");
|
|
537
544
|
this.go(page);
|
|
538
|
-
|
|
539
|
-
if (this.#
|
|
545
|
+
buttonRoute.classList.add("route_active");
|
|
546
|
+
if (this.#autoClose) {
|
|
540
547
|
// Панель закрывается после перехода: состояние и значок меняет тот же код,
|
|
541
548
|
// что и по Escape — иначе у кнопки осталось бы открытое aria-expanded
|
|
542
549
|
if (this.#appMenu.style.transform === `translateX(${this.#appMenuSlide}px)`) this.#close();
|
|
@@ -544,19 +551,19 @@ class AppMenu {
|
|
|
544
551
|
}
|
|
545
552
|
});
|
|
546
553
|
if (page.getIcon() !== undefined) {
|
|
547
|
-
|
|
554
|
+
buttonRoute.appendChild(iconMenu);
|
|
548
555
|
}
|
|
549
|
-
|
|
550
|
-
this.#routeViews.appendChild(
|
|
556
|
+
buttonRoute.appendChild(titleMenu);
|
|
557
|
+
this.#routeViews.appendChild(buttonRoute);
|
|
551
558
|
this.#routeKeyboard.refresh();
|
|
552
559
|
// Новый пункт мог не пройти уже введённый фильтр — список и подсказка пересобираются
|
|
553
|
-
this.#filterRoutes(this.#
|
|
560
|
+
this.#filterRoutes(this.#appMenuSearchInput.value);
|
|
554
561
|
} else {
|
|
555
|
-
Log.error(I18n.t("appLayout.search.alreadyAdded", {title: page.getTitle()}))
|
|
562
|
+
Log.error(I18n.t("appLayout.search.alreadyAdded", { title: page.getTitle() }));
|
|
556
563
|
}
|
|
557
564
|
}
|
|
558
565
|
setAutoClose() {
|
|
559
|
-
this.#
|
|
566
|
+
this.#autoClose = true;
|
|
560
567
|
}
|
|
561
568
|
getMenu() {
|
|
562
569
|
return this.#appMenu;
|
|
@@ -564,125 +571,128 @@ class AppMenu {
|
|
|
564
571
|
}
|
|
565
572
|
|
|
566
573
|
class NotificationBox {
|
|
567
|
-
#
|
|
568
|
-
#
|
|
574
|
+
#notificationBox = document.createElement("notification_box");
|
|
575
|
+
#notificationBoxMain = document.createElement("notification_box_main");
|
|
569
576
|
// Шапка ящика: заголовок со счётчиком истории и действие «Очистить»
|
|
570
|
-
#
|
|
571
|
-
#
|
|
572
|
-
#
|
|
573
|
-
#
|
|
574
|
-
#
|
|
575
|
-
|
|
576
|
-
#
|
|
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");
|
|
577
586
|
// Состав ящика меняет Notification — за списком следит observer
|
|
578
587
|
#observer = undefined;
|
|
579
|
-
#
|
|
580
|
-
#
|
|
581
|
-
#
|
|
588
|
+
#notificationBoxWidth = 400;
|
|
589
|
+
#notificationBoxWidthTest = 425;
|
|
590
|
+
#notificationButton = document.createElement("notification_button");
|
|
582
591
|
constructor(header, center, layout) {
|
|
583
|
-
layout.set(LAYOUT_ROLE.NOTIFICATION_BOX, this.#
|
|
584
|
-
layout.set(LAYOUT_ROLE.NOTIFICATION_BOX_MAIN, this.#
|
|
585
|
-
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";
|
|
586
595
|
// Шапка: заголовок, счётчик записей и очистка списка
|
|
587
|
-
this.#
|
|
588
|
-
this.#
|
|
589
|
-
this.#
|
|
590
|
-
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);
|
|
591
600
|
// Очистка — обычная кнопка-действие, а не Button: в шапке нужна тихая ссылка-действие
|
|
592
|
-
this.#
|
|
593
|
-
this.#
|
|
594
|
-
this.#
|
|
595
|
-
this.#
|
|
596
|
-
this.#
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
this.#
|
|
600
|
-
this.#
|
|
601
|
-
this.#
|
|
602
|
-
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);
|
|
603
612
|
// Пустое состояние — общий компонент, а не своя разметка из значка и подписи: это был
|
|
604
613
|
// третий вариант одного и того же блока (здесь, в поиске меню, в таблицах). Заодно он
|
|
605
614
|
// объявляется скринридеру (role="status"), а прежняя заглушка молчала
|
|
606
|
-
this.#
|
|
615
|
+
this.#emptyState = new EmptyState({
|
|
607
616
|
icon: Icons.SOCIAL.NOTIFICATIONS_OFF,
|
|
608
617
|
title: I18n.t("appLayout.notifications.empty"),
|
|
609
|
-
compact: true
|
|
618
|
+
compact: true,
|
|
610
619
|
});
|
|
611
|
-
this.#
|
|
612
|
-
this.#
|
|
613
|
-
this.#
|
|
614
|
-
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);
|
|
615
624
|
// Карточки кладёт снаружи Notification, поэтому счётчик, группы и пустое состояние
|
|
616
625
|
// следим по списку. Разделители дней ящик вставляет сам: на них реакция не нужна,
|
|
617
626
|
// иначе пересборка дёргала бы сама себя без конца
|
|
618
627
|
this.#observer = new MutationObserver((records) => this.#onBoxMutated(records));
|
|
619
|
-
this.#observer.observe(this.#
|
|
628
|
+
this.#observer.observe(this.#notificationBoxMain, { childList: true });
|
|
620
629
|
this.#syncState();
|
|
621
|
-
this.#
|
|
622
|
-
this.#
|
|
623
|
-
this.#
|
|
624
|
-
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();
|
|
625
634
|
else this.#openPanel();
|
|
626
|
-
})
|
|
635
|
+
});
|
|
627
636
|
// Значок — украшение: без подписи имя кнопки складывалось из лигатуры («notifications»)
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
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);
|
|
631
640
|
// Имя кнопки — в том числе «непрочитанных: N» — держит #syncUnread: одно место
|
|
632
641
|
// Клавиатура и aria панели уведомлений
|
|
633
|
-
makeKeyboardButton(this.#
|
|
634
|
-
this.#
|
|
635
|
-
this.#
|
|
636
|
-
this.#
|
|
637
|
-
this.#
|
|
638
|
-
this.#
|
|
639
|
-
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) => {
|
|
640
649
|
if (event.key !== "Escape") return;
|
|
641
650
|
event.preventDefault();
|
|
642
651
|
this.#closePanel();
|
|
643
|
-
this.#
|
|
652
|
+
this.#notificationButton.focus();
|
|
644
653
|
});
|
|
645
654
|
// Панель открывается с кнопки, и фокус остаётся на ней же — Escape обязан закрывать
|
|
646
655
|
// ящик и оттуда, иначе выйти с клавиатуры можно было только Tab'ом внутрь панели
|
|
647
|
-
this.#
|
|
656
|
+
this.#notificationButton.addEventListener("keydown", (event) => {
|
|
648
657
|
if (event.key !== "Escape") return;
|
|
649
658
|
event.preventDefault();
|
|
650
659
|
this.#closePanel();
|
|
651
660
|
});
|
|
652
|
-
header.set().addEventListener(
|
|
653
|
-
if (e.target !== this.#
|
|
654
|
-
})
|
|
655
|
-
center.addEventListener(
|
|
656
|
-
if (!this.#
|
|
657
|
-
if (this.#
|
|
661
|
+
header.set().addEventListener("click", (e) => {
|
|
662
|
+
if (e.target !== this.#notificationButton) this.#closePanel();
|
|
663
|
+
});
|
|
664
|
+
center.addEventListener("click", (e) => {
|
|
665
|
+
if (!this.#notificationBox.contains(e.target)) {
|
|
666
|
+
if (this.#notificationBox.style.transform === `translateX(-${this.#notificationBoxWidthTest}px)`)
|
|
667
|
+
this.#closePanel();
|
|
658
668
|
}
|
|
659
|
-
})
|
|
669
|
+
});
|
|
660
670
|
center.onscroll = () => {
|
|
661
671
|
// Прокрутка меняет материал шапки: плотнее фон и тень. Класс, а не инлайновые стили:
|
|
662
672
|
// вид полосы целиком описан в global_style.css
|
|
663
673
|
header.set().classList.toggle("header_scrolled", center.scrollTop > 15);
|
|
664
674
|
};
|
|
665
|
-
header.addToRight(this.#
|
|
675
|
+
header.addToRight(this.#notificationButton);
|
|
666
676
|
}
|
|
667
677
|
#openPanel() {
|
|
668
|
-
this.#
|
|
669
|
-
this.#
|
|
670
|
-
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");
|
|
671
681
|
// Открытый ящик — значит прочитанный: отметки снимаются все сразу, а не по одной
|
|
672
682
|
this.#markRead();
|
|
673
683
|
}
|
|
674
684
|
#closePanel() {
|
|
675
|
-
this.#
|
|
676
|
-
this.#
|
|
677
|
-
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");
|
|
678
688
|
}
|
|
679
689
|
// Записи истории: карточки в ящик кладёт Notification, поэтому состав читаем из DOM
|
|
680
690
|
#notifications() {
|
|
681
|
-
return [...this.#
|
|
691
|
+
return [...this.#notificationBoxMain.children].filter((child) => child.tagName === "NOTIFICATION");
|
|
682
692
|
}
|
|
683
693
|
// Разделители дней — тоже прямые дети списка, но записями не являются
|
|
684
694
|
#groups() {
|
|
685
|
-
return [...this.#
|
|
695
|
+
return [...this.#notificationBoxMain.children].filter((child) => child.tagName === "NOTIFICATION_GROUP");
|
|
686
696
|
}
|
|
687
697
|
/*
|
|
688
698
|
* Реакция на изменение состава ящика.
|
|
@@ -693,17 +703,19 @@ class NotificationBox {
|
|
|
693
703
|
*/
|
|
694
704
|
#onBoxMutated(records = []) {
|
|
695
705
|
const changed = records.some((record) =>
|
|
696
|
-
[...record.addedNodes, ...record.removedNodes].some((node) => node.tagName === "NOTIFICATION")
|
|
706
|
+
[...record.addedNodes, ...record.removedNodes].some((node) => node.tagName === "NOTIFICATION")
|
|
707
|
+
);
|
|
697
708
|
if (changed) this.#syncState();
|
|
698
709
|
}
|
|
699
710
|
// Счётчик, пустое состояние, группы дней и доступность кнопки согласованы с составом ящика
|
|
700
711
|
#syncState() {
|
|
701
712
|
const notifications = this.#notifications();
|
|
702
713
|
const count = notifications.length;
|
|
703
|
-
this.#
|
|
704
|
-
this.#
|
|
705
|
-
|
|
706
|
-
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);
|
|
707
719
|
this.#group(notifications);
|
|
708
720
|
this.#syncUnread(notifications);
|
|
709
721
|
}
|
|
@@ -716,12 +728,15 @@ class NotificationBox {
|
|
|
716
728
|
* по тегу (счётчик, очистка), работает без изменений.
|
|
717
729
|
*/
|
|
718
730
|
#group(notifications = []) {
|
|
731
|
+
// Идёт каскад уборки: подписи уже гаснут своим кадром, а пересборка заменила бы их
|
|
732
|
+
// новыми и вернула бы рывок, который каскад и убирает
|
|
733
|
+
if (this.#clearing) return;
|
|
719
734
|
for (const separator of this.#groups()) separator.remove();
|
|
720
735
|
let day = undefined;
|
|
721
736
|
for (const notification of notifications) {
|
|
722
737
|
if (notification.dataset.day === day) continue;
|
|
723
738
|
day = notification.dataset.day;
|
|
724
|
-
this.#
|
|
739
|
+
this.#notificationBoxMain.insertBefore(this.#makeGroup(day), notification);
|
|
725
740
|
}
|
|
726
741
|
}
|
|
727
742
|
/** Подпись дня: сегодня и вчера — словами, раньше — датой */
|
|
@@ -729,8 +744,9 @@ class NotificationBox {
|
|
|
729
744
|
const yesterday = new Date();
|
|
730
745
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
731
746
|
const group = document.createElement("notification_group");
|
|
732
|
-
if (day ===
|
|
733
|
-
else if (day ===
|
|
747
|
+
if (day === chuiFunctions.getDayKey()) group.innerText = I18n.t("appLayout.notifications.today");
|
|
748
|
+
else if (day === chuiFunctions.getDayKey(yesterday))
|
|
749
|
+
group.innerText = I18n.t("appLayout.notifications.yesterday");
|
|
734
750
|
else {
|
|
735
751
|
// Ключ хранится как ГГГГ-ММ-ДД, а в подписи порядок привычный: ДД.ММ.ГГГГ
|
|
736
752
|
const [year, month, date] = String(day).split("-");
|
|
@@ -747,26 +763,79 @@ class NotificationBox {
|
|
|
747
763
|
*/
|
|
748
764
|
#syncUnread(notifications = []) {
|
|
749
765
|
const unread = notifications.filter((notification) => notification.dataset.unread === "true").length;
|
|
750
|
-
this.#
|
|
751
|
-
const label =
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
this.#
|
|
766
|
+
this.#notificationButton.classList.toggle("notification_button_unread", unread > 0);
|
|
767
|
+
const label =
|
|
768
|
+
unread === 0
|
|
769
|
+
? I18n.t("appLayout.notifications.title")
|
|
770
|
+
: I18n.t("appLayout.notifications.unreadCount", { count: unread });
|
|
771
|
+
this.#notificationButton.setAttribute("aria-label", label);
|
|
772
|
+
this.#notificationButton.setAttribute("title", label);
|
|
756
773
|
}
|
|
757
774
|
#markRead() {
|
|
758
775
|
for (const notification of this.#notifications()) notification.dataset.unread = "false";
|
|
759
776
|
this.#syncUnread(this.#notifications());
|
|
760
777
|
}
|
|
778
|
+
/*
|
|
779
|
+
* Очистка истории.
|
|
780
|
+
*
|
|
781
|
+
* Карточки уходят каскадом: номер в очереди задаёт `--notification_clear_index`, а шаг —
|
|
782
|
+
* доля токена движения, поэтому весь список читается как рассыпающийся, а не как
|
|
783
|
+
* выключенный одним щелчком. Подписи дней гаснут вместе с ним, пустое состояние
|
|
784
|
+
* и счётчик появляются после последнего кадра.
|
|
785
|
+
*
|
|
786
|
+
* Конец каскада считает срок из тех же токенов движения, а не ждёт `animationend`:
|
|
787
|
+
* у карточки, которую браузер не отрисовывает (скрытая панель, `display: none`), кадр
|
|
788
|
+
* вообще не проигрывается, и событие не придёт — уборка осталась бы висеть
|
|
789
|
+
* незавершённой вместе с погашенными подписями.
|
|
790
|
+
*/
|
|
761
791
|
#clearAll() {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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;
|
|
766
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();
|
|
833
|
+
}
|
|
834
|
+
for (const group of this.#groups()) group.remove();
|
|
835
|
+
this.#syncState();
|
|
767
836
|
}
|
|
768
837
|
getBox() {
|
|
769
|
-
return this.#
|
|
838
|
+
return this.#notificationBox;
|
|
770
839
|
}
|
|
771
840
|
}
|
|
772
841
|
|
|
@@ -776,7 +845,7 @@ class AppLayout {
|
|
|
776
845
|
#center = new Center(this.#layout).set();
|
|
777
846
|
#appMenu = new AppMenu(this.#header, this.#center, this.#layout);
|
|
778
847
|
#notificationBox = new NotificationBox(this.#header, this.#center, this.#layout);
|
|
779
|
-
#notificationPanel = document.createElement(
|
|
848
|
+
#notificationPanel = document.createElement("notification_panel");
|
|
780
849
|
// Текущие уведомления: ipc-обработчики регистрируются один раз и работают с последним
|
|
781
850
|
#downloadNotification = undefined;
|
|
782
851
|
#updateNotificationLoad = undefined;
|
|
@@ -785,18 +854,22 @@ class AppLayout {
|
|
|
785
854
|
// Слушатели живут весь процесс — обработчик ошибок обязан быть на месте всегда.
|
|
786
855
|
#onWindowError = (event) => {
|
|
787
856
|
const place = event.filename === undefined ? "" : ` (${event.filename}:${event.lineno})`;
|
|
788
|
-
this.#logError(
|
|
789
|
-
|
|
857
|
+
this.#logError(
|
|
858
|
+
I18n.t("appLayout.error.uncaught"),
|
|
859
|
+
event.error === undefined ? event.message : event.error,
|
|
860
|
+
place
|
|
861
|
+
);
|
|
862
|
+
};
|
|
790
863
|
#onUnhandledRejection = (event) => {
|
|
791
|
-
this.#logError(I18n.t("appLayout.error.unhandledRejection"), event.reason)
|
|
792
|
-
}
|
|
864
|
+
this.#logError(I18n.t("appLayout.error.unhandledRejection"), event.reason);
|
|
865
|
+
};
|
|
793
866
|
#logError(prefix, value, place = "") {
|
|
794
867
|
const text = `${prefix}: ${AppLayout.#formatError(value)}${place}`;
|
|
795
868
|
try {
|
|
796
|
-
Log.error(text)
|
|
869
|
+
Log.error(text);
|
|
797
870
|
} catch (e) {
|
|
798
871
|
// Сам обработчик ошибок падать не должен (например, ipcRenderer уже недоступен при закрытии окна)
|
|
799
|
-
console.error(text, e)
|
|
872
|
+
console.error(text, e);
|
|
800
873
|
}
|
|
801
874
|
}
|
|
802
875
|
static #formatError(value) {
|
|
@@ -805,24 +878,24 @@ class AppLayout {
|
|
|
805
878
|
return String(value);
|
|
806
879
|
}
|
|
807
880
|
constructor() {
|
|
808
|
-
require(
|
|
881
|
+
require("../../modules/chui_fonts").install();
|
|
809
882
|
// Глобальные стили
|
|
810
|
-
|
|
811
|
-
|
|
883
|
+
chuiFunctions.setStyles(__dirname + `/global_style.css`, "chUiJS_Global_App");
|
|
884
|
+
chuiFunctions.setStyles(__dirname + `/main_theme_style.css`, "chUiJS_Main_Theme");
|
|
812
885
|
// Необработанные ошибки и reject’ы renderer-процесса — один раз на процесс
|
|
813
|
-
if (
|
|
814
|
-
|
|
886
|
+
if (appErrorHandlersInstalled === false) {
|
|
887
|
+
appErrorHandlersInstalled = true;
|
|
815
888
|
window.addEventListener("error", this.#onWindowError);
|
|
816
889
|
window.addEventListener("unhandledrejection", this.#onUnhandledRejection);
|
|
817
890
|
}
|
|
818
891
|
//if (process.platform !== "darwin") document.body.style.border = "1px solid var(--border_color)";
|
|
819
892
|
// ===
|
|
820
893
|
|
|
821
|
-
document.body.appendChild(this.#header.set())
|
|
822
|
-
document.body.appendChild(this.#center)
|
|
823
|
-
document.body.appendChild(this.#appMenu.getMenu())
|
|
824
|
-
document.body.appendChild(this.#notificationBox.getBox())
|
|
825
|
-
document.body.appendChild(this.#notificationPanel)
|
|
894
|
+
document.body.appendChild(this.#header.set());
|
|
895
|
+
document.body.appendChild(this.#center);
|
|
896
|
+
document.body.appendChild(this.#appMenu.getMenu());
|
|
897
|
+
document.body.appendChild(this.#notificationBox.getBox());
|
|
898
|
+
document.body.appendChild(this.#notificationPanel);
|
|
826
899
|
// Части layout'а зарегистрированы: уведомления и Route ищут свои узлы по ролям, а не по id
|
|
827
900
|
this.#layout.set(LAYOUT_ROLE.NOTIFICATION_PANEL, this.#notificationPanel);
|
|
828
901
|
registerLayout(this.#layout);
|
|
@@ -837,41 +910,43 @@ class AppLayout {
|
|
|
837
910
|
ipcRenderer.on("sendNotificationDownload", (e, text, body) => {
|
|
838
911
|
this.#downloadNotification = new DownloadNotification({ title: text, text: body });
|
|
839
912
|
this.#downloadNotification.show();
|
|
840
|
-
})
|
|
913
|
+
});
|
|
841
914
|
ipcRenderer.on("sendNotificationDownloadUpdate", (e, title, text) => {
|
|
842
915
|
if (this.#downloadNotification !== undefined) this.#downloadNotification.update(title, text);
|
|
843
|
-
})
|
|
916
|
+
});
|
|
844
917
|
ipcRenderer.on("sendNotificationDownloadComplete", () => {
|
|
845
918
|
if (this.#downloadNotification !== undefined) this.#downloadNotification.done();
|
|
846
|
-
})
|
|
919
|
+
});
|
|
847
920
|
ipcRenderer.on("sendNotificationDownloadError", () => {
|
|
848
921
|
if (this.#downloadNotification !== undefined) this.#downloadNotification.error();
|
|
849
|
-
})
|
|
922
|
+
});
|
|
850
923
|
//
|
|
851
924
|
ipcRenderer.on("sendNotificationUpdateLoad", (e, text, body) => {
|
|
852
925
|
this.#updateNotificationLoad = new UpdateNotification({ title: text, text: body, spinner: true });
|
|
853
926
|
this.#updateNotificationLoad.show(true);
|
|
854
|
-
})
|
|
927
|
+
});
|
|
855
928
|
ipcRenderer.on("sendNotificationUpdateLoadClose", () => {
|
|
856
929
|
if (this.#updateNotificationLoad !== undefined) this.#updateNotificationLoad.hide();
|
|
857
|
-
})
|
|
930
|
+
});
|
|
858
931
|
ipcRenderer.on("sendNotificationUpdate", (e, text, body) => {
|
|
859
932
|
this.#updateNotification = new UpdateNotification({ title: text, text: body, spinner: false });
|
|
860
933
|
this.#updateNotification.show(true);
|
|
861
|
-
})
|
|
934
|
+
});
|
|
862
935
|
ipcRenderer.on("sendNotificationUpdateClose", () => {
|
|
863
936
|
if (this.#updateNotification !== undefined) this.#updateNotification.hide();
|
|
864
|
-
})
|
|
865
|
-
|
|
937
|
+
});
|
|
938
|
+
const popup = new Popup();
|
|
866
939
|
ipcRenderer.once("checkUpdatesTrue", async (e, check, version) => {
|
|
867
940
|
if (check) {
|
|
868
|
-
|
|
869
|
-
title: I18n.t("appLayout.update.available", {version: version}),
|
|
870
|
-
|
|
941
|
+
const confirmRes = await popup.confirm({
|
|
942
|
+
title: I18n.t("appLayout.update.available", { version: version }),
|
|
943
|
+
message: I18n.t("appLayout.update.question"),
|
|
944
|
+
cancelText: I18n.t("appLayout.update.cancel"),
|
|
945
|
+
okText: I18n.t("appLayout.update.install"),
|
|
871
946
|
});
|
|
872
|
-
e.sender.send("updateInstallConfirm",
|
|
947
|
+
e.sender.send("updateInstallConfirm", confirmRes);
|
|
873
948
|
}
|
|
874
|
-
})
|
|
949
|
+
});
|
|
875
950
|
}
|
|
876
951
|
setWindowControlsLeft() {
|
|
877
952
|
this.#header.addWC(true);
|
|
@@ -898,83 +973,103 @@ class AppLayout {
|
|
|
898
973
|
this.#header.addToLeftBeforeTitle(...headerLeft);
|
|
899
974
|
}
|
|
900
975
|
disableAppMenu() {
|
|
901
|
-
this.#layout.get(LAYOUT_ROLE.APP_MENU_BUTTON).remove()
|
|
976
|
+
this.#layout.get(LAYOUT_ROLE.APP_MENU_BUTTON).remove();
|
|
902
977
|
// Отступ имени страницы считает CSS: `.page_name_no_menu` в global_style.css
|
|
903
|
-
this.#layout.get(LAYOUT_ROLE.PAGE_NAME).classList.add("page_name_no_menu")
|
|
978
|
+
this.#layout.get(LAYOUT_ROLE.PAGE_NAME).classList.add("page_name_no_menu");
|
|
904
979
|
}
|
|
905
980
|
setScript(pathToJS = String(), id = String()) {
|
|
906
|
-
|
|
907
|
-
|
|
981
|
+
const data = fs.readFileSync(pathToJS, "utf8");
|
|
982
|
+
const script = document.createElement("script");
|
|
908
983
|
script.innerHTML = String(data);
|
|
909
|
-
script.setAttribute(
|
|
984
|
+
script.setAttribute("id", id);
|
|
910
985
|
// Скрипт подключается в сам документ (не узел компонента), поэтому проверка по id и document
|
|
911
986
|
if (document.querySelector(`script[id="${id}"]`) === null) document.body.appendChild(script);
|
|
912
987
|
}
|
|
913
|
-
static TABS(
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
988
|
+
static TABS(
|
|
989
|
+
options = {
|
|
990
|
+
width: String(),
|
|
991
|
+
default: Number(),
|
|
992
|
+
tabs: [],
|
|
993
|
+
}
|
|
994
|
+
) {
|
|
995
|
+
return new HeaderTabs(options).set();
|
|
996
|
+
}
|
|
997
|
+
static BUTTON(
|
|
998
|
+
options = {
|
|
999
|
+
title: String(),
|
|
1000
|
+
icon: undefined,
|
|
1001
|
+
reverse: Boolean(),
|
|
1002
|
+
clickEvent: () => {},
|
|
1003
|
+
}
|
|
1004
|
+
) {
|
|
1005
|
+
return new HeaderButton(options).set();
|
|
1006
|
+
}
|
|
1007
|
+
static USER_PROFILE(
|
|
1008
|
+
options = {
|
|
1009
|
+
username: String(),
|
|
1010
|
+
image: { noImage: Boolean(), imageLink: String(), imageBase64: String(), path: String() },
|
|
1011
|
+
items: [],
|
|
1012
|
+
}
|
|
1013
|
+
) {
|
|
1014
|
+
return new UserProfile(options).set();
|
|
1015
|
+
}
|
|
1016
|
+
static USER_PROFILE_ITEM(
|
|
1017
|
+
options = {
|
|
1018
|
+
title: String(),
|
|
1019
|
+
icon: undefined,
|
|
1020
|
+
clickEvent: () => {},
|
|
1021
|
+
}
|
|
1022
|
+
) {
|
|
1023
|
+
return new UserDDItem(options).set();
|
|
1024
|
+
}
|
|
1025
|
+
static DIALOG(
|
|
1026
|
+
options = {
|
|
940
1027
|
title: String(),
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1028
|
+
icon: undefined,
|
|
1029
|
+
reverse: Boolean(),
|
|
1030
|
+
label: String(),
|
|
1031
|
+
dialogOptions: {
|
|
1032
|
+
title: String(),
|
|
1033
|
+
closeOutSideClick: Boolean(),
|
|
1034
|
+
style: {
|
|
1035
|
+
width: String(),
|
|
1036
|
+
height: String(),
|
|
1037
|
+
direction: String(),
|
|
1038
|
+
wrap: String(),
|
|
1039
|
+
align: String(),
|
|
1040
|
+
justify: String(),
|
|
1041
|
+
},
|
|
1042
|
+
components: [],
|
|
949
1043
|
},
|
|
950
|
-
components: []
|
|
951
1044
|
}
|
|
952
|
-
|
|
1045
|
+
) {
|
|
1046
|
+
return new HeaderDialog(options).set();
|
|
1047
|
+
}
|
|
953
1048
|
}
|
|
954
1049
|
|
|
955
1050
|
class HeaderTabs {
|
|
956
|
-
#
|
|
957
|
-
#
|
|
958
|
-
#
|
|
959
|
-
#
|
|
1051
|
+
#idList = require("randomstring").generate();
|
|
1052
|
+
#headerTabsList = undefined;
|
|
1053
|
+
#headerTabs = document.createElement("header_tabs");
|
|
1054
|
+
#keydownEvent = (event) => this.#onKeydown(event);
|
|
960
1055
|
constructor(options = { width: String(), default: Number(), tabs: [] }) {
|
|
961
|
-
this.#
|
|
962
|
-
this.#
|
|
1056
|
+
this.#headerTabsList = options.tabs;
|
|
1057
|
+
this.#headerTabs.id = this.#idList;
|
|
963
1058
|
// Полоса вкладок — не меню: у неё свои роли (`tablist`/`tab`) и свои клавиши (см. #onKeydown).
|
|
964
1059
|
// `role="button"` и `tabindex="0"` каждой вкладке уже поставил makeKeyboardButton, поэтому
|
|
965
1060
|
// роли и порядок обхода переопределяем здесь: остановка Tab в полосе должна быть ровно одна
|
|
966
|
-
this.#
|
|
967
|
-
for (
|
|
1061
|
+
this.#headerTabs.setAttribute("role", "tablist");
|
|
1062
|
+
for (const item of this.#headerTabsList) {
|
|
968
1063
|
item.setAttribute("role", "tab");
|
|
969
1064
|
item.setAttribute("tabindex", "-1");
|
|
970
|
-
this.#
|
|
971
|
-
item.addEventListener(
|
|
1065
|
+
this.#headerTabs.appendChild(item);
|
|
1066
|
+
item.addEventListener("click", (event) => {
|
|
972
1067
|
if (event.target.tagName === "HEADER_BUTTON_ICON" || event.target.tagName === "HEADER_BUTTON_TITLE") {
|
|
973
|
-
this.#setActive(event.target.parentNode)
|
|
1068
|
+
this.#setActive(event.target.parentNode);
|
|
974
1069
|
} else {
|
|
975
|
-
this.#setActive(event.target)
|
|
1070
|
+
this.#setActive(event.target);
|
|
976
1071
|
}
|
|
977
|
-
})
|
|
1072
|
+
});
|
|
978
1073
|
}
|
|
979
1074
|
// Клик по выбранной вкладке не переигрывает её действие — как у пункта меню текущего
|
|
980
1075
|
// раздела (`route_active`, который тоже не переходит повторно). Раньше клик до вкладки
|
|
@@ -982,40 +1077,44 @@ class HeaderTabs {
|
|
|
982
1077
|
// кликом он снимал у неё и курсор-указатель, и ховер, которых у соседних кнопок шапки
|
|
983
1078
|
// нет. Слушатель ловит клик в фазе перехвата: действие вкладки и `#setActive` висят
|
|
984
1079
|
// на самом элементе и иначе сработали бы первыми
|
|
985
|
-
this.#
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1080
|
+
this.#headerTabs.addEventListener(
|
|
1081
|
+
"click",
|
|
1082
|
+
(event) => {
|
|
1083
|
+
const tab = event.target.closest === undefined ? null : event.target.closest("header_button");
|
|
1084
|
+
if (tab === null || tab.getAttribute("active") === null) return;
|
|
1085
|
+
event.stopImmediatePropagation();
|
|
1086
|
+
},
|
|
1087
|
+
true
|
|
1088
|
+
);
|
|
990
1089
|
// Пока ни одна вкладка не выбрана, остановкой обхода становится первая по счёту:
|
|
991
1090
|
// иначе в полосу нельзя было бы войти с клавиатуры вообще
|
|
992
|
-
const
|
|
993
|
-
if (
|
|
994
|
-
this.#
|
|
995
|
-
if (options.width !== undefined) this.#
|
|
996
|
-
for (
|
|
997
|
-
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) {
|
|
998
1097
|
setTimeout(() => {
|
|
999
|
-
item.click()
|
|
1098
|
+
item.click();
|
|
1000
1099
|
//this.#setActive(item)
|
|
1001
|
-
}, 250)
|
|
1100
|
+
}, 250);
|
|
1002
1101
|
}
|
|
1003
1102
|
}
|
|
1004
1103
|
}
|
|
1005
1104
|
set() {
|
|
1006
|
-
return this.#
|
|
1105
|
+
return this.#headerTabs;
|
|
1007
1106
|
}
|
|
1008
1107
|
#setActive(target) {
|
|
1009
1108
|
if (target.getAttribute("active") === null) {
|
|
1010
1109
|
// `children`, а не `childNodes`: в `childNodes` попадают и текстовые узлы, у которых нет classList.
|
|
1011
1110
|
// `children` — HTMLCollection, поэтому перебираем копию
|
|
1012
|
-
[...this.#
|
|
1111
|
+
[...this.#headerTabs.children].forEach((child) => {
|
|
1013
1112
|
child.removeAttribute("active");
|
|
1014
1113
|
child.removeAttribute("aria-current");
|
|
1015
1114
|
child.removeAttribute("aria-selected");
|
|
1016
1115
|
child.setAttribute("tabindex", "-1");
|
|
1017
|
-
child.classList.remove("header_tab_active")
|
|
1018
|
-
})
|
|
1116
|
+
child.classList.remove("header_tab_active");
|
|
1117
|
+
});
|
|
1019
1118
|
target.setAttribute("active", true);
|
|
1020
1119
|
// Вкладка без `active` была только подсвечена классом: скринридер о выборе не знал (как у пункта меню маршрутов)
|
|
1021
1120
|
target.setAttribute("aria-current", "page");
|
|
@@ -1023,7 +1122,7 @@ class HeaderTabs {
|
|
|
1023
1122
|
target.setAttribute("aria-selected", "true");
|
|
1024
1123
|
// Выбранная вкладка — единственная остановка Tab в полосе вкладок
|
|
1025
1124
|
target.setAttribute("tabindex", "0");
|
|
1026
|
-
target.classList.add("header_tab_active")
|
|
1125
|
+
target.classList.add("header_tab_active");
|
|
1027
1126
|
}
|
|
1028
1127
|
}
|
|
1029
1128
|
/*
|
|
@@ -1036,11 +1135,11 @@ class HeaderTabs {
|
|
|
1036
1135
|
* `makeKeyboardButton` (клик по выбранной вкладке гасит перехватчик выше).
|
|
1037
1136
|
*/
|
|
1038
1137
|
#onKeydown(event) {
|
|
1039
|
-
const tabs = [...this.#
|
|
1138
|
+
const tabs = [...this.#headerTabs.children];
|
|
1040
1139
|
if (tabs.length === 0) return;
|
|
1041
1140
|
const focused = tabs.indexOf(document.activeElement);
|
|
1042
1141
|
// Фокус мог быть не в полосе: тогда считаем от вкладки с остановкой обхода
|
|
1043
|
-
const from = focused === -1 ? tabs.findIndex(tab => tab.getAttribute("tabindex") === "0") : focused;
|
|
1142
|
+
const from = focused === -1 ? tabs.findIndex((tab) => tab.getAttribute("tabindex") === "0") : focused;
|
|
1044
1143
|
const start = from === -1 ? 0 : from;
|
|
1045
1144
|
const last = tabs.length - 1;
|
|
1046
1145
|
let next;
|
|
@@ -1059,12 +1158,12 @@ class UserProfile {
|
|
|
1059
1158
|
// находится по узлу обёртки — так же, как у MenuBar, и по нему его снимает тот,
|
|
1060
1159
|
// кто убирает профиль из шапки (`Header.removeToRight`)
|
|
1061
1160
|
static #instances = new WeakMap();
|
|
1062
|
-
#
|
|
1063
|
-
#
|
|
1064
|
-
#
|
|
1065
|
-
#
|
|
1161
|
+
#userMain = document.createElement("user_main");
|
|
1162
|
+
#userButton = document.createElement("user_button");
|
|
1163
|
+
#userDropdown = document.createElement("user_dropdown");
|
|
1164
|
+
#isOpen = false;
|
|
1066
1165
|
//
|
|
1067
|
-
#
|
|
1166
|
+
#userDdImageMain = document.createElement("user_dd_image_main");
|
|
1068
1167
|
// Аватар — компонент Avatar: инициалы, картинка из адреса/base64/файла
|
|
1069
1168
|
#avatar = undefined;
|
|
1070
1169
|
#username = undefined;
|
|
@@ -1072,35 +1171,37 @@ class UserProfile {
|
|
|
1072
1171
|
// Слушатель window нужен только пока меню открыто: иначе он копился на каждый профиль.
|
|
1073
1172
|
// Закрывает меню только по клику СНАРУЖИ: раньше сравнивался прямой родитель цели,
|
|
1074
1173
|
// поэтому клик по аватару или пустому месту внутри меню тоже его закрывал
|
|
1075
|
-
#
|
|
1076
|
-
if (this.#
|
|
1174
|
+
#windowClickEvent = (event) => {
|
|
1175
|
+
if (this.#userMain.contains(event.target) || this.#userDropdown.contains(event.target)) return;
|
|
1077
1176
|
this.#closeMenu();
|
|
1078
|
-
}
|
|
1177
|
+
};
|
|
1079
1178
|
// Выбор пункта закрывает меню. Обработчик на списке, а не на каждом пункте: пункты
|
|
1080
1179
|
// приходят из UserDDItem и могут быть добавлены кем угодно снаружи
|
|
1081
|
-
#
|
|
1180
|
+
#dropdownClickEvent = (event) => {
|
|
1082
1181
|
const item = event.target.closest === undefined ? null : event.target.closest("user_item");
|
|
1083
|
-
if (item === null || !this.#
|
|
1182
|
+
if (item === null || !this.#userDropdown.contains(item)) return;
|
|
1084
1183
|
this.#closeMenu();
|
|
1085
|
-
}
|
|
1184
|
+
};
|
|
1086
1185
|
//
|
|
1087
|
-
constructor(
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1186
|
+
constructor(
|
|
1187
|
+
options = {
|
|
1188
|
+
username: String(),
|
|
1189
|
+
image: { noImage: Boolean(), imageLink: String(), imageBase64: String(), path: String() },
|
|
1190
|
+
items: [],
|
|
1191
|
+
}
|
|
1192
|
+
) {
|
|
1092
1193
|
this.#username = options.username;
|
|
1093
|
-
this.#
|
|
1094
|
-
this.#
|
|
1095
|
-
UserProfile.#instances.set(this.#
|
|
1194
|
+
this.#userButton.innerText = options.username;
|
|
1195
|
+
this.#userMain.appendChild(this.#userButton);
|
|
1196
|
+
UserProfile.#instances.set(this.#userMain, this);
|
|
1096
1197
|
// Панель — слой в body: внутри шапки (у неё свой backdrop-filter) её blur не работал бы,
|
|
1097
1198
|
// а выдвижные слои оболочки перекрывали бы её. Идентификатор нужен для aria-controls
|
|
1098
|
-
this.#
|
|
1099
|
-
document.body.appendChild(this.#
|
|
1100
|
-
this.#
|
|
1101
|
-
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();
|
|
1102
1203
|
else this.#openMenu();
|
|
1103
|
-
})
|
|
1204
|
+
});
|
|
1104
1205
|
//
|
|
1105
1206
|
if (options.image !== undefined) {
|
|
1106
1207
|
// Имя пользователя уже есть в кнопке и в пунктах меню, поэтому аватар — украшение
|
|
@@ -1108,24 +1209,24 @@ class UserProfile {
|
|
|
1108
1209
|
name: options.username,
|
|
1109
1210
|
image: options.image,
|
|
1110
1211
|
size: "80px",
|
|
1111
|
-
decorative: true
|
|
1212
|
+
decorative: true,
|
|
1112
1213
|
});
|
|
1113
|
-
this.#
|
|
1114
|
-
this.#
|
|
1214
|
+
this.#userDdImageMain.appendChild(this.#avatar.set());
|
|
1215
|
+
this.#userDropdown.appendChild(this.#userDdImageMain);
|
|
1115
1216
|
}
|
|
1116
1217
|
//
|
|
1117
|
-
for (
|
|
1118
|
-
this.#
|
|
1218
|
+
for (const item of options.items) this.#userDropdown.appendChild(item);
|
|
1219
|
+
this.#userDropdown.addEventListener("click", this.#dropdownClickEvent);
|
|
1119
1220
|
// Клавиатура и aria меню профиля: стрелки по пунктам, Escape закрывает и возвращает фокус
|
|
1120
|
-
this.#menuKeyboard = new MenuKeyboard(this.#
|
|
1221
|
+
this.#menuKeyboard = new MenuKeyboard(this.#userDropdown, {
|
|
1121
1222
|
itemSelector: "user_item",
|
|
1122
1223
|
onEscape: () => this.#closeMenu(),
|
|
1123
|
-
trigger: this.#
|
|
1224
|
+
trigger: this.#userButton,
|
|
1124
1225
|
});
|
|
1125
|
-
makeKeyboardButton(this.#
|
|
1226
|
+
makeKeyboardButton(this.#userButton);
|
|
1126
1227
|
// Закрытое меню объявляется закрытым сразу: иначе у кнопки не было бы aria-expanded
|
|
1127
1228
|
this.#menuKeyboard.setExpanded(false);
|
|
1128
|
-
this.#
|
|
1229
|
+
this.#userButton.addEventListener("keydown", (event) => {
|
|
1129
1230
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
1130
1231
|
event.preventDefault();
|
|
1131
1232
|
this.#openMenu();
|
|
@@ -1134,40 +1235,40 @@ class UserProfile {
|
|
|
1134
1235
|
});
|
|
1135
1236
|
}
|
|
1136
1237
|
#openMenu() {
|
|
1137
|
-
this.#
|
|
1238
|
+
this.#isOpen = true;
|
|
1138
1239
|
// Панель живёт в body: если её снимал `unmount`, возвращаем на место
|
|
1139
|
-
if (document.body.contains(this.#
|
|
1140
|
-
this.#
|
|
1240
|
+
if (document.body.contains(this.#userDropdown) === false) document.body.appendChild(this.#userDropdown);
|
|
1241
|
+
this.#userButton.classList.add(USER_PROFILE_ACTIVE_CLASS);
|
|
1141
1242
|
// fadeIn показывает панель (display: flex), и только после этого её можно измерить
|
|
1142
|
-
new Animation(this.#
|
|
1243
|
+
new Animation(this.#userDropdown).fadeIn();
|
|
1143
1244
|
this.#positionDropdown();
|
|
1144
|
-
window.addEventListener("click", this.#
|
|
1145
|
-
window.addEventListener("resize", this.#
|
|
1245
|
+
window.addEventListener("click", this.#windowClickEvent);
|
|
1246
|
+
window.addEventListener("resize", this.#positionDropdownEvent);
|
|
1146
1247
|
this.#menuKeyboard.setExpanded(true);
|
|
1147
1248
|
}
|
|
1148
1249
|
#closeMenu() {
|
|
1149
|
-
if (!this.#
|
|
1150
|
-
this.#
|
|
1151
|
-
this.#
|
|
1152
|
-
new Animation(this.#
|
|
1153
|
-
window.removeEventListener("click", this.#
|
|
1154
|
-
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);
|
|
1155
1256
|
this.#menuKeyboard.setExpanded(false);
|
|
1156
1257
|
}
|
|
1157
1258
|
// position: fixed — координаты считаются от окна, поэтому панель пересчитывается и на resize.
|
|
1158
1259
|
// Логический `inset-inline-end` держит выравнивание по кнопке и в RTL
|
|
1159
1260
|
#positionDropdown() {
|
|
1160
|
-
const button = this.#
|
|
1161
|
-
const dropdown = this.#
|
|
1261
|
+
const button = this.#userButton.getBoundingClientRect();
|
|
1262
|
+
const dropdown = this.#userDropdown.getBoundingClientRect();
|
|
1162
1263
|
let end = window.innerWidth - button.right;
|
|
1163
1264
|
if (end + dropdown.width > window.innerWidth - USER_DROPDOWN_EDGE) {
|
|
1164
1265
|
end = window.innerWidth - USER_DROPDOWN_EDGE - dropdown.width;
|
|
1165
1266
|
}
|
|
1166
1267
|
if (end < USER_DROPDOWN_EDGE) end = USER_DROPDOWN_EDGE;
|
|
1167
|
-
this.#
|
|
1168
|
-
this.#
|
|
1268
|
+
this.#userDropdown.style.insetInlineEnd = `${Math.round(end)}px`;
|
|
1269
|
+
this.#userDropdown.style.top = `${Math.round(button.bottom + USER_DROPDOWN_GAP)}px`;
|
|
1169
1270
|
}
|
|
1170
|
-
#
|
|
1271
|
+
#positionDropdownEvent = () => this.#positionDropdown();
|
|
1171
1272
|
|
|
1172
1273
|
getUsername() {
|
|
1173
1274
|
return this.#username;
|
|
@@ -1185,8 +1286,8 @@ class UserProfile {
|
|
|
1185
1286
|
*/
|
|
1186
1287
|
unmount() {
|
|
1187
1288
|
this.#closeMenu();
|
|
1188
|
-
this.#
|
|
1189
|
-
UserProfile.#instances.delete(this.#
|
|
1289
|
+
this.#userDropdown.remove();
|
|
1290
|
+
UserProfile.#instances.delete(this.#userMain);
|
|
1190
1291
|
return this;
|
|
1191
1292
|
}
|
|
1192
1293
|
/** Полное удаление: панель больше не нужна — снять клавиатуру и убрать панель из body */
|
|
@@ -1206,144 +1307,149 @@ class UserProfile {
|
|
|
1206
1307
|
}
|
|
1207
1308
|
|
|
1208
1309
|
set() {
|
|
1209
|
-
return this.#
|
|
1310
|
+
return this.#userMain;
|
|
1210
1311
|
}
|
|
1211
1312
|
}
|
|
1212
1313
|
|
|
1213
1314
|
class UserDDItem {
|
|
1214
|
-
#
|
|
1315
|
+
#userItem = document.createElement("user_item");
|
|
1215
1316
|
|
|
1216
|
-
constructor(
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1317
|
+
constructor(
|
|
1318
|
+
options = {
|
|
1319
|
+
title: String(),
|
|
1320
|
+
icon: undefined,
|
|
1321
|
+
clickEvent: () => {},
|
|
1322
|
+
}
|
|
1323
|
+
) {
|
|
1221
1324
|
if (options.title !== undefined && options.icon !== undefined) {
|
|
1222
|
-
this.#
|
|
1325
|
+
this.#userItem.innerText = options.title;
|
|
1223
1326
|
// Значок ведёт строку — как в пунктах context_menu_item и выпадающих списков
|
|
1224
1327
|
// меню-бара. Дописанный в конец, он прилипал к подписи, и пункт меню профиля
|
|
1225
1328
|
// выбивался из семьи меню (в остальных меню значок стоит перед текстом)
|
|
1226
|
-
this.#
|
|
1329
|
+
this.#userItem.insertAdjacentHTML("afterbegin", new Icon(options.icon).getHTML());
|
|
1227
1330
|
} else if (options.title !== undefined) {
|
|
1228
|
-
this.#
|
|
1331
|
+
this.#userItem.innerText = options.title;
|
|
1229
1332
|
} else if (options.icon !== undefined) {
|
|
1230
|
-
this.#
|
|
1333
|
+
this.#userItem.innerHTML = new Icon(options.icon).getHTML();
|
|
1231
1334
|
}
|
|
1232
|
-
this.#
|
|
1335
|
+
this.#userItem.addEventListener("click", options.clickEvent);
|
|
1233
1336
|
}
|
|
1234
1337
|
|
|
1235
1338
|
set() {
|
|
1236
|
-
return this.#
|
|
1339
|
+
return this.#userItem;
|
|
1237
1340
|
}
|
|
1238
1341
|
}
|
|
1239
1342
|
|
|
1240
1343
|
class HeaderDialog {
|
|
1241
|
-
#
|
|
1242
|
-
constructor(
|
|
1243
|
-
|
|
1244
|
-
icon: undefined,
|
|
1245
|
-
reverse: Boolean(),
|
|
1246
|
-
label: String(),
|
|
1247
|
-
dialogOptions: {
|
|
1344
|
+
#headerButton = undefined;
|
|
1345
|
+
constructor(
|
|
1346
|
+
options = {
|
|
1248
1347
|
title: String(),
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1348
|
+
icon: undefined,
|
|
1349
|
+
reverse: Boolean(),
|
|
1350
|
+
label: String(),
|
|
1351
|
+
dialogOptions: {
|
|
1352
|
+
title: String(),
|
|
1353
|
+
closeOutSideClick: Boolean(),
|
|
1354
|
+
style: {
|
|
1355
|
+
width: String(),
|
|
1356
|
+
height: String(),
|
|
1357
|
+
direction: String(),
|
|
1358
|
+
wrap: String(),
|
|
1359
|
+
align: String(),
|
|
1360
|
+
justify: String(),
|
|
1361
|
+
},
|
|
1362
|
+
components: [],
|
|
1257
1363
|
},
|
|
1258
|
-
components: []
|
|
1259
1364
|
}
|
|
1260
|
-
|
|
1365
|
+
) {
|
|
1261
1366
|
// Неполные `dialogOptions` сборку не роняют: `style` и `components` — необязательные
|
|
1262
1367
|
// части опции, а прямое обращение к ним давало TypeError при создании кнопки
|
|
1263
1368
|
// («Cannot read properties of undefined») ещё до первого открытия слоя
|
|
1264
|
-
const
|
|
1265
|
-
const
|
|
1266
|
-
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;
|
|
1267
1372
|
// Пустой заголовок остаётся пустым: заготовка опции рисовала бы слово «undefined»
|
|
1268
|
-
const
|
|
1269
|
-
? "" :
|
|
1270
|
-
|
|
1271
|
-
width:
|
|
1272
|
-
height:
|
|
1273
|
-
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,
|
|
1274
1379
|
// Имя модального слоя для скринридера: без него диалог объявлялся бы безымянным
|
|
1275
|
-
title:
|
|
1276
|
-
})
|
|
1380
|
+
title: dialogTitle,
|
|
1381
|
+
});
|
|
1277
1382
|
// HEADER
|
|
1278
|
-
|
|
1383
|
+
const headerDialog = new ContentBlock({
|
|
1279
1384
|
direction: "row",
|
|
1280
1385
|
wrap: "nowrap",
|
|
1281
1386
|
align: "center",
|
|
1282
1387
|
justify: "space-between",
|
|
1283
|
-
disableMarginChild: true
|
|
1388
|
+
disableMarginChild: true,
|
|
1284
1389
|
});
|
|
1285
|
-
|
|
1390
|
+
headerDialog.setWidth("-webkit-fill-available");
|
|
1286
1391
|
// Заголовок — как у заголовков панелей оболочки (размер `--font_default_size`, вес 500
|
|
1287
1392
|
// даёт `strong`): отдельные 14 pt в диалоге спорили с его содержимым
|
|
1288
|
-
|
|
1289
|
-
markdownText: `**${
|
|
1393
|
+
const title = new Label({
|
|
1394
|
+
markdownText: `**${dialogTitle}**`,
|
|
1290
1395
|
wordBreak: "normal",
|
|
1291
|
-
width: "max-content"
|
|
1292
|
-
})
|
|
1293
|
-
|
|
1396
|
+
width: "max-content",
|
|
1397
|
+
});
|
|
1398
|
+
const closeButton = new Button({
|
|
1294
1399
|
icon: Icons.NAVIGATION.CLOSE,
|
|
1295
1400
|
label: I18n.t("common.close"),
|
|
1296
1401
|
reverse: true,
|
|
1297
|
-
clickEvent: () => dialog.close()
|
|
1402
|
+
clickEvent: () => dialog.close(),
|
|
1298
1403
|
});
|
|
1299
|
-
|
|
1300
|
-
dialog.addToHeader(
|
|
1404
|
+
headerDialog.add(title, closeButton);
|
|
1405
|
+
dialog.addToHeader(headerDialog);
|
|
1301
1406
|
// BODY
|
|
1302
|
-
|
|
1303
|
-
direction:
|
|
1304
|
-
wrap:
|
|
1305
|
-
align:
|
|
1306
|
-
justify:
|
|
1407
|
+
const bodyDialog = new ContentBlock({
|
|
1408
|
+
direction: dialogStyle.direction,
|
|
1409
|
+
wrap: dialogStyle.wrap,
|
|
1410
|
+
align: dialogStyle.align,
|
|
1411
|
+
justify: dialogStyle.justify,
|
|
1307
1412
|
});
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
dialog.addToBody(
|
|
1413
|
+
bodyDialog.setWidth("-webkit-fill-available");
|
|
1414
|
+
bodyDialog.add(...dialogComponents);
|
|
1415
|
+
dialog.addToBody(bodyDialog);
|
|
1311
1416
|
// FOOTER
|
|
1312
1417
|
|
|
1313
1418
|
//
|
|
1314
|
-
this.#
|
|
1419
|
+
this.#headerButton = makeHeaderButtonBox(options);
|
|
1315
1420
|
// Слой идёт в body при первом открытии, а не при сборке: заранее созданный
|
|
1316
1421
|
// невидимый диалог оставался бы в документе на всё время жизни кнопки
|
|
1317
|
-
this.#
|
|
1422
|
+
this.#headerButton.addEventListener("click", () => {
|
|
1318
1423
|
if (document.body.contains(dialog.set()) === false) document.body.appendChild(dialog.set());
|
|
1319
1424
|
dialog.open();
|
|
1320
1425
|
});
|
|
1321
1426
|
}
|
|
1322
1427
|
|
|
1323
1428
|
set() {
|
|
1324
|
-
return this.#
|
|
1429
|
+
return this.#headerButton;
|
|
1325
1430
|
}
|
|
1326
1431
|
}
|
|
1327
1432
|
|
|
1328
1433
|
class HeaderButton {
|
|
1329
|
-
#
|
|
1434
|
+
#headerButton = undefined;
|
|
1330
1435
|
|
|
1331
|
-
constructor(
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1436
|
+
constructor(
|
|
1437
|
+
options = {
|
|
1438
|
+
title: String(),
|
|
1439
|
+
icon: undefined,
|
|
1440
|
+
reverse: Boolean(),
|
|
1441
|
+
label: String(),
|
|
1442
|
+
clickEvent: () => {},
|
|
1337
1443
|
}
|
|
1338
|
-
|
|
1339
|
-
this.#
|
|
1340
|
-
this.#
|
|
1444
|
+
) {
|
|
1445
|
+
this.#headerButton = makeHeaderButtonBox(options);
|
|
1446
|
+
this.#headerButton.addEventListener("click", options.clickEvent);
|
|
1341
1447
|
}
|
|
1342
1448
|
|
|
1343
1449
|
set() {
|
|
1344
|
-
return this.#
|
|
1450
|
+
return this.#headerButton;
|
|
1345
1451
|
}
|
|
1346
1452
|
}
|
|
1347
1453
|
|
|
1348
|
-
exports.AppLayout = AppLayout
|
|
1349
|
-
exports.Route = Route
|
|
1454
|
+
exports.AppLayout = AppLayout;
|
|
1455
|
+
exports.Route = Route;
|