diff --git a/app/shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/Platform.android.kt b/app/shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/Platform.android.kt index a1e9c886c..41b3c4f1e 100644 --- a/app/shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/Platform.android.kt +++ b/app/shared/src/androidMain/kotlin/org/jetbrains/kotlinconf/Platform.android.kt @@ -2,4 +2,4 @@ package org.jetbrains.kotlinconf actual fun getPlatformId(): String = "android" - +internal actual val isMacPlatform: Boolean = false \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/Platform.kt b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/Platform.kt index 5c92cde0d..71cce53c1 100644 --- a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/Platform.kt +++ b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/Platform.kt @@ -1,3 +1,5 @@ package org.jetbrains.kotlinconf internal expect fun getPlatformId(): String + +internal expect val isMacPlatform: Boolean \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.kt b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.kt new file mode 100644 index 000000000..54724ee8f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.kt @@ -0,0 +1,13 @@ +package org.jetbrains.kotlinconf + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import org.jetbrains.kotlinconf.ui.components.MainHeaderContainerState + +/** + * It handles Ctrl/Cmd + F key events when the component is focused. + */ +@Composable +expect fun Modifier.searchShortcut(onTriggered: () -> Unit): Modifier \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt index d9e8445ad..ce910458e 100644 --- a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt +++ b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/ScheduleScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.liveRegion @@ -46,6 +47,7 @@ import org.jetbrains.kotlinconf.ScrollToTopHandler import org.jetbrains.kotlinconf.SessionCardView import org.jetbrains.kotlinconf.SessionId import org.jetbrains.kotlinconf.SessionState +import org.jetbrains.kotlinconf.searchShortcut import org.jetbrains.kotlinconf.generated.resources.Res import org.jetbrains.kotlinconf.generated.resources.nav_destination_schedule import org.jetbrains.kotlinconf.generated.resources.schedule_action_filter_bookmarked @@ -144,11 +146,19 @@ fun ScheduleScreen( viewModel.setSearchParams(params) } + val searchBarFocusRequester = remember { FocusRequester() } Column( modifier = Modifier .fillMaxSize() .background(color = KotlinConfTheme.colors.mainBackground) .padding(topInsetPadding()) + .searchShortcut { + if (headerState != MainHeaderContainerState.Search) { + headerState = MainHeaderContainerState.Search + } + // ensure the field is focused + searchBarFocusRequester.requestFocus() + } ) { Header( startContent = { @@ -163,7 +173,8 @@ fun ScheduleScreen( searchQuery = searchQuery, onSearchQueryChange = { searchQuery = it }, onClearSearch = { viewModel.resetFilters() }, - viewModel = viewModel + searchBarFocusRequester = searchBarFocusRequester, + viewModel = viewModel, ) HorizontalDivider( thickness = 1.dp, @@ -308,6 +319,7 @@ private fun Header( searchQuery: String, onSearchQueryChange: (String) -> Unit, onClearSearch: () -> Unit, + searchBarFocusRequester: FocusRequester, viewModel: ScheduleViewModel, ) { MainHeaderContainer( @@ -354,6 +366,7 @@ private fun Header( }, onClear = onClearSearch, hasAdditionalInputs = filterItems.any { it.isSelected }, + searchBarFocusRequester = searchBarFocusRequester, ) } ) diff --git a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt index 76d403d3e..6476a54e2 100644 --- a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt +++ b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt @@ -15,8 +15,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics @@ -31,6 +33,7 @@ import org.jetbrains.compose.resources.stringResource import org.jetbrains.kotlinconf.HideKeyboardOnDragHandler import org.jetbrains.kotlinconf.ScrollToTopHandler import org.jetbrains.kotlinconf.SpeakerId +import org.jetbrains.kotlinconf.searchShortcut import org.jetbrains.kotlinconf.generated.resources.Res import org.jetbrains.kotlinconf.generated.resources.speakers_error_no_data import org.jetbrains.kotlinconf.generated.resources.speakers_number_of_results @@ -74,10 +77,18 @@ fun SpeakersScreen( viewModel.setSearchText(searchText) } + val searchBarFocusRequester = remember { FocusRequester() } Column( Modifier.fillMaxSize() .background(color = KotlinConfTheme.colors.mainBackground) .padding(topInsetPadding()) + .searchShortcut { + if (searchState != MainHeaderContainerState.Search) { + searchState = MainHeaderContainerState.Search + } + // ensure the field is focused + searchBarFocusRequester.requestFocus() + } ) { MainHeaderContainer( state = searchState, @@ -111,6 +122,7 @@ fun SpeakersScreen( searchText = "" }, onClear = { searchText = "" }, + searchBarFocusRequester = searchBarFocusRequester, ) } ) diff --git a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/licenses/LicenseScreens.kt b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/licenses/LicenseScreens.kt index cfb6f8ff2..ff08a0ca4 100644 --- a/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/licenses/LicenseScreens.kt +++ b/app/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/licenses/LicenseScreens.kt @@ -18,11 +18,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics @@ -41,6 +43,7 @@ import org.jetbrains.compose.resources.stringResource import org.jetbrains.kotlinconf.HideKeyboardOnDragHandler import org.jetbrains.kotlinconf.ScreenWithTitle import org.jetbrains.kotlinconf.ScrollToTopHandler +import org.jetbrains.kotlinconf.searchShortcut import org.jetbrains.kotlinconf.generated.resources.Res import org.jetbrains.kotlinconf.generated.resources.licenses_number_of_results import org.jetbrains.kotlinconf.generated.resources.licenses_title @@ -87,11 +90,19 @@ fun LicensesScreen( viewModel.setSearchText(searchText) } + val searchBarFocusRequester = remember { FocusRequester() } Column( modifier = Modifier .fillMaxSize() .background(color = KotlinConfTheme.colors.mainBackground) .padding(topInsetPadding()) + .searchShortcut { + if (searchState != MainHeaderContainerState.Search) { + searchState = MainHeaderContainerState.Search + } + // ensure the field is focused + searchBarFocusRequester.requestFocus() + } ) { MainHeaderContainer( state = searchState, @@ -132,6 +143,7 @@ fun LicensesScreen( searchText = "" }, onClear = { searchText = "" }, + searchBarFocusRequester = searchBarFocusRequester, ) } ) diff --git a/app/shared/src/nonAndroidMain/kotlin/org/jetbrains/kotlinconf/Platform.nonAndroid.kt b/app/shared/src/nonAndroidMain/kotlin/org/jetbrains/kotlinconf/Platform.nonAndroid.kt new file mode 100644 index 000000000..694c18af7 --- /dev/null +++ b/app/shared/src/nonAndroidMain/kotlin/org/jetbrains/kotlinconf/Platform.nonAndroid.kt @@ -0,0 +1,7 @@ +package org.jetbrains.kotlinconf + +import org.jetbrains.skiko.hostOs + +internal actual val isMacPlatform: Boolean by lazy { + hostOs.isMacOS +} \ No newline at end of file diff --git a/app/shared/src/nonWebMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.nonWeb.kt b/app/shared/src/nonWebMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.nonWeb.kt new file mode 100644 index 000000000..ade838580 --- /dev/null +++ b/app/shared/src/nonWebMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.nonWeb.kt @@ -0,0 +1,53 @@ +package org.jetbrains.kotlinconf + +import androidx.compose.foundation.focusable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type + +@Composable +actual fun Modifier.searchShortcut(onTriggered: () -> Unit): Modifier { + val focusRequester = remember { FocusRequester() } + var hasFocus by remember { mutableStateOf(false) } + + LaunchedEffect(hasFocus) { + if (!hasFocus) { + runCatching { focusRequester.requestFocus() } + } + } + + return this + .onFocusChanged { hasFocus = it.hasFocus } + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + val modifierPressed = if (isMacPlatform) { + event.isMetaPressed && !event.isCtrlPressed + } else { + event.isCtrlPressed && !event.isMetaPressed + } + val isFindShortcut = event.type == KeyEventType.KeyDown + && event.key == Key.F + && modifierPressed + if (isFindShortcut) { + onTriggered() + true + } else { + false + } + } +} diff --git a/app/shared/src/webMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.kt b/app/shared/src/webMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.kt new file mode 100644 index 000000000..1345f3ec6 --- /dev/null +++ b/app/shared/src/webMain/kotlin/org/jetbrains/kotlinconf/SearchShortcutHandler.kt @@ -0,0 +1,36 @@ +package org.jetbrains.kotlinconf + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import kotlinx.browser.document +import org.w3c.dom.events.Event +import org.w3c.dom.events.KeyboardEvent + +// Web needs its own implementation, because to handle the key events on canvas it has to be focused, +// but it's not always the case. +// To be improved in Compose Multiplatform for web - https://youtrack.jetbrains.com/issue/CMP-9948 +@Composable +actual fun Modifier.searchShortcut(onTriggered: () -> Unit): Modifier { + val currentTrigger by rememberUpdatedState(onTriggered) + DisposableEffect(Unit) { + val listener: (Event) -> Unit = { event -> + val keyboardEvent = event as KeyboardEvent + val modifierPressed = if (isMacPlatform) { + keyboardEvent.metaKey && !keyboardEvent.ctrlKey + } else { + keyboardEvent.ctrlKey && !keyboardEvent.metaKey + } + val isFindShortcut = modifierPressed && (keyboardEvent.key == "f" || keyboardEvent.key == "F") + if (isFindShortcut) { + event.preventDefault() + currentTrigger() + } + } + document.addEventListener("keydown", listener) + onDispose { document.removeEventListener("keydown", listener) } + } + return this +} diff --git a/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainHeader.kt b/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainHeader.kt index 74edb6b83..69dad2cb4 100644 --- a/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainHeader.kt +++ b/app/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/MainHeader.kt @@ -24,6 +24,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -48,12 +54,26 @@ fun MainHeaderSearchBar( onClose: () -> Unit, modifier: Modifier = Modifier, hasAdditionalInputs: Boolean = false, + searchBarFocusRequester: FocusRequester? = null, ) { + + fun KeyEvent.isEscPressed(): Boolean = + this.type == KeyEventType.KeyDown && this.key == Key.Escape + Row( modifier = modifier .height(48.dp) .fillMaxWidth() - .background(KotlinConfTheme.colors.mainBackground), + .background(KotlinConfTheme.colors.mainBackground) + .onPreviewKeyEvent { event -> + if (event.isEscPressed()) { + onClose() + onSearchValueChange("") + true + } else { + false + } + }, verticalAlignment = Alignment.CenterVertically, ) { TopMenuButton( @@ -66,7 +86,7 @@ fun MainHeaderSearchBar( ) var focusRequested by rememberSaveable { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } + val focusRequester = searchBarFocusRequester ?: remember { FocusRequester() } if (!focusRequested) { LaunchedEffect(Unit) { focusRequester.requestFocus()