Add tab to Settings dialog to view keyboard shortcuts (#2567)
* Add keyboard custom icon * Refactor Settings to be more modular * Add basic keybindings view to settings * Add more shortcuts * Add link to see keyboard shortcuts tab * Little more bottom padding * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Add keybindings to settings search * Add a playwright test for opening the the keyboard shortcuts * fmt --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@ -257,6 +257,14 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
keyboard: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 12V15H13.5M16 12V9M16 12H13.5M4 12V15H6.5M4 12V9M4 12H6.5M4 9V6H6.5M4 9H6.5M16 9V6H13.5M16 9H13.5M6.5 12V15M6.5 12H7.5M6.5 15H13.5M13.5 15V12M13.5 12H12.5M7.5 12V9M7.5 12H10M7.5 9H8.75M7.5 9H6.5M10 12V9M10 12H12.5M10 9H11.25M10 9H8.75M12.5 12V9M12.5 9H13.5M12.5 9H11.25M13.5 9V6M13.5 6H11.25M11.25 9V6M11.25 6H8.75M8.75 9V6M8.75 6H6.5M6.5 9V6"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
line: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
|
@ -81,6 +81,12 @@ export function HelpMenu(props: React.PropsWithChildren) {
|
||||
>
|
||||
Release notes
|
||||
</HelpMenuItem>
|
||||
<HelpMenuItem
|
||||
as="button"
|
||||
onClick={() => navigate('settings?tab=keybindings')}
|
||||
>
|
||||
Keyboard shortcuts
|
||||
</HelpMenuItem>
|
||||
<HelpMenuItem
|
||||
as="button"
|
||||
onClick={() => {
|
||||
|
87
src/components/Settings/AllKeybindingsFields.tsx
Normal file
87
src/components/Settings/AllKeybindingsFields.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
InteractionMapItem,
|
||||
interactionMap,
|
||||
sortInteractionMapByCategory,
|
||||
} from 'lib/settings/initialKeybindings'
|
||||
import { ForwardedRef, forwardRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
interface AllKeybindingsFieldsProps {}
|
||||
|
||||
export const AllKeybindingsFields = forwardRef(
|
||||
(
|
||||
props: AllKeybindingsFieldsProps,
|
||||
scrollRef: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
// This is how we will get the interaction map from the context
|
||||
// in the future whene franknoirot/editable-hotkeys is merged.
|
||||
// const { state } = useInteractionMapContext()
|
||||
|
||||
return (
|
||||
<div className="relative overflow-y-auto pb-16">
|
||||
<div ref={scrollRef} className="flex flex-col gap-12">
|
||||
{Object.entries(interactionMap)
|
||||
.sort(sortInteractionMapByCategory)
|
||||
.map(([category, categoryItems]) => (
|
||||
<div className="flex flex-col gap-4 px-2 pr-4">
|
||||
<h2
|
||||
id={`category-${category}`}
|
||||
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||
>
|
||||
{category}
|
||||
</h2>
|
||||
{categoryItems.map((item) => (
|
||||
<KeybindingField
|
||||
key={category + '-' + item.name}
|
||||
category={category}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
function KeybindingField({
|
||||
item,
|
||||
category,
|
||||
}: {
|
||||
item: InteractionMapItem
|
||||
category: string
|
||||
}) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex gap-16 justify-between items-start py-1 px-2 -my-1 -mx-2 ' +
|
||||
(location.hash === `#${item.name}`
|
||||
? 'bg-primary/5 dark:bg-chalkboard-90'
|
||||
: '')
|
||||
}
|
||||
id={item.name}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-normal capitalize tracking-wide">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-50">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-wrap justify-end gap-3">
|
||||
{item.sequence.split(' ').map((chord, i) => (
|
||||
<kbd
|
||||
key={`${category}-${item.name}-${chord}-${i}`}
|
||||
className="py-0.5 px-1.5 rounded bg-primary/10 dark:bg-chalkboard-80"
|
||||
>
|
||||
{chord}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
238
src/components/Settings/AllSettingsFields.tsx
Normal file
238
src/components/Settings/AllSettingsFields.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import decamelize from 'decamelize'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import {
|
||||
shouldHideSetting,
|
||||
shouldShowSettingInput,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import { SettingsFieldInput } from './SettingsFieldInput'
|
||||
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
|
||||
import toast from 'react-hot-toast'
|
||||
import { APP_VERSION } from 'routes/Settings'
|
||||
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
|
||||
import { paths } from 'lib/paths'
|
||||
import { useDotDotSlash } from 'hooks/useDotDotSlash'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { ForwardedRef, forwardRef } from 'react'
|
||||
|
||||
interface AllSettingsFieldsProps {
|
||||
searchParamTab: SettingsLevel
|
||||
isFileSettings: boolean
|
||||
}
|
||||
|
||||
export const AllSettingsFields = forwardRef(
|
||||
(
|
||||
{ searchParamTab, isFileSettings }: AllSettingsFieldsProps,
|
||||
scrollRef: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const dotDotSlash = useDotDotSlash()
|
||||
const {
|
||||
settings: { send, context },
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
const projectPath =
|
||||
isFileSettings && isTauri()
|
||||
? decodeURI(
|
||||
location.pathname
|
||||
.replace(paths.FILE + '/', '')
|
||||
.replace(paths.SETTINGS, '')
|
||||
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
||||
)
|
||||
: undefined
|
||||
|
||||
function restartOnboarding() {
|
||||
send({
|
||||
type: `set.app.onboardingStatus`,
|
||||
data: { level: 'user', value: '' },
|
||||
})
|
||||
|
||||
if (isFileSettings) {
|
||||
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
|
||||
} else {
|
||||
createAndOpenNewProject(navigate)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-y-auto">
|
||||
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
|
||||
{Object.entries(context)
|
||||
.filter(([_, categorySettings]) =>
|
||||
// Filter out categories that don't have any non-hidden settings
|
||||
Object.values(categorySettings).some(
|
||||
(setting) => !shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category, categorySettings]) => (
|
||||
<Fragment key={category}>
|
||||
<h2
|
||||
id={`category-${category}`}
|
||||
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||
>
|
||||
{decamelize(category, { separator: ' ' })}
|
||||
</h2>
|
||||
{Object.entries(categorySettings)
|
||||
.filter(
|
||||
// Filter out settings that don't have a Component or inputType
|
||||
// or are hidden on the current level or the current platform
|
||||
(item: [string, Setting<unknown>]) =>
|
||||
shouldShowSettingInput(item[1], searchParamTab)
|
||||
)
|
||||
.map(([settingName, s]) => {
|
||||
const setting = s as Setting
|
||||
const parentValue =
|
||||
setting[setting.getParentLevel(searchParamTab)]
|
||||
return (
|
||||
<SettingsSection
|
||||
title={decamelize(settingName, {
|
||||
separator: ' ',
|
||||
})}
|
||||
id={settingName}
|
||||
className={
|
||||
location.hash === `#${settingName}`
|
||||
? 'bg-primary/5 dark:bg-chalkboard-90'
|
||||
: ''
|
||||
}
|
||||
key={`${category}-${settingName}-${searchParamTab}`}
|
||||
description={setting.description}
|
||||
settingHasChanged={
|
||||
setting[searchParamTab] !== undefined &&
|
||||
setting[searchParamTab] !==
|
||||
setting.getFallback(searchParamTab)
|
||||
}
|
||||
parentLevel={setting.getParentLevel(searchParamTab)}
|
||||
onFallback={() =>
|
||||
send({
|
||||
type: `set.${category}.${settingName}`,
|
||||
data: {
|
||||
level: searchParamTab,
|
||||
value:
|
||||
parentValue !== undefined
|
||||
? parentValue
|
||||
: setting.getFallback(searchParamTab),
|
||||
},
|
||||
} as SetEventTypes)
|
||||
}
|
||||
>
|
||||
<SettingsFieldInput
|
||||
category={category}
|
||||
settingName={settingName}
|
||||
settingsLevel={searchParamTab}
|
||||
setting={setting}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
|
||||
Resets
|
||||
</h2>
|
||||
<SettingsSection
|
||||
title="Onboarding"
|
||||
description="Replay the onboarding process"
|
||||
>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={restartOnboarding}
|
||||
iconStart={{
|
||||
icon: 'refresh',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
>
|
||||
Replay Onboarding
|
||||
</ActionButton>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title="Reset settings"
|
||||
description={`Restore settings to their default values. Your settings are saved in
|
||||
${
|
||||
isTauri()
|
||||
? ' a file in the app data folder for your OS.'
|
||||
: " your browser's local storage."
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
{isTauri() && (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={async () => {
|
||||
const paths = await getSettingsFolderPaths(
|
||||
projectPath ? decodeURIComponent(projectPath) : undefined
|
||||
)
|
||||
showInFolder(paths[searchParamTab])
|
||||
}}
|
||||
iconStart={{
|
||||
icon: 'folder',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
>
|
||||
Show in folder
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={async () => {
|
||||
const defaultDirectory = await getInitialDefaultDir()
|
||||
send({
|
||||
type: 'Reset settings',
|
||||
defaultDirectory,
|
||||
})
|
||||
toast.success('Settings restored to default')
|
||||
}}
|
||||
iconStart={{
|
||||
icon: 'refresh',
|
||||
size: 'sm',
|
||||
className: 'p-1 text-chalkboard-10',
|
||||
bgClassName: 'bg-destroy-70',
|
||||
}}
|
||||
>
|
||||
Restore default settings
|
||||
</ActionButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
|
||||
About Modeling App
|
||||
</h2>
|
||||
<div className="text-sm mb-12">
|
||||
<p>
|
||||
{/* This uses a Vite plugin, set in vite.config.ts
|
||||
to inject the version from package.json */}
|
||||
App version {APP_VERSION}.{' '}
|
||||
<a
|
||||
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View release on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p className="max-w-2xl mt-6">
|
||||
Don't see the feature you want? Check to see if it's on{' '}
|
||||
<a
|
||||
href="https://github.com/KittyCAD/modeling-app/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
our roadmap
|
||||
</a>
|
||||
, and start a discussion if you don't see it! Your feedback will
|
||||
help us prioritize what to build next.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
35
src/components/Settings/KeybindingsSectionsList.tsx
Normal file
35
src/components/Settings/KeybindingsSectionsList.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import {
|
||||
interactionMap,
|
||||
sortInteractionMapByCategory,
|
||||
} from 'lib/settings/initialKeybindings'
|
||||
|
||||
interface KeybindingSectionsListProps {
|
||||
scrollRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function KeybindingsSectionsList({
|
||||
scrollRef,
|
||||
}: KeybindingSectionsListProps) {
|
||||
return (
|
||||
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||
{Object.entries(interactionMap)
|
||||
.sort(sortInteractionMapByCategory)
|
||||
.map(([category]) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() =>
|
||||
scrollRef.current
|
||||
?.querySelector(`#category-${category}`)
|
||||
?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -3,11 +3,23 @@ import { CustomIcon } from 'components/CustomIcon'
|
||||
import decamelize from 'decamelize'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { interactionMap } from 'lib/settings/initialKeybindings'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
type ExtendedSettingsLevel = SettingsLevel | 'keybindings'
|
||||
|
||||
export type SettingsSearchItem = {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
category: string
|
||||
level: ExtendedSettingsLevel
|
||||
}
|
||||
|
||||
export function SettingsSearchBar() {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
useHotkeys(
|
||||
@ -21,29 +33,40 @@ export function SettingsSearchBar() {
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const settingsAsSearchable = useMemo(
|
||||
() =>
|
||||
Object.entries(settings.state.context).flatMap(
|
||||
const settingsAsSearchable: SettingsSearchItem[] = useMemo(
|
||||
() => [
|
||||
...Object.entries(settings.state.context).flatMap(
|
||||
([category, categorySettings]) =>
|
||||
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
|
||||
const s = setting as Setting
|
||||
return ['project', 'user']
|
||||
return (['project', 'user'] satisfies SettingsLevel[])
|
||||
.filter((l) => s.hideOnLevel !== l)
|
||||
.map((l) => ({
|
||||
category: decamelize(category, { separator: ' ' }),
|
||||
settingName: settingName,
|
||||
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
|
||||
setting: s,
|
||||
level: l,
|
||||
name: settingName,
|
||||
description: s.description ?? '',
|
||||
displayName: decamelize(settingName, { separator: ' ' }),
|
||||
level: l as ExtendedSettingsLevel,
|
||||
}))
|
||||
})
|
||||
),
|
||||
...Object.entries(interactionMap).flatMap(
|
||||
([category, categoryKeybindings]) =>
|
||||
categoryKeybindings.map((keybinding) => ({
|
||||
name: keybinding.name,
|
||||
displayName: keybinding.title,
|
||||
description: keybinding.description,
|
||||
category: category,
|
||||
level: 'keybindings' as ExtendedSettingsLevel,
|
||||
}))
|
||||
),
|
||||
],
|
||||
[settings.state.context]
|
||||
)
|
||||
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
|
||||
|
||||
const fuse = new Fuse(settingsAsSearchable, {
|
||||
keys: ['category', 'settingNameDisplay', 'setting.description'],
|
||||
keys: ['category', 'displayName', 'description'],
|
||||
includeScore: true,
|
||||
})
|
||||
|
||||
@ -52,16 +75,8 @@ export function SettingsSearchBar() {
|
||||
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
|
||||
}, [query])
|
||||
|
||||
function handleSelection({
|
||||
level,
|
||||
settingName,
|
||||
}: {
|
||||
category: string
|
||||
settingName: string
|
||||
setting: Setting<unknown>
|
||||
level: string
|
||||
}) {
|
||||
navigate(`?tab=${level}#${settingName}`)
|
||||
function handleSelection({ level, name }: SettingsSearchItem) {
|
||||
navigate(`?tab=${level}#${name}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -87,18 +102,18 @@ export function SettingsSearchBar() {
|
||||
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
|
||||
{searchResults?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={`${option.category}-${option.settingName}-${option.level}`}
|
||||
key={`${option.category}-${option.name}-${option.level}`}
|
||||
value={option}
|
||||
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
|
||||
>
|
||||
<p className="flex-grow text-base capitalize m-0 leading-none">
|
||||
{option.level} ·{' '}
|
||||
{decamelize(option.category, { separator: ' ' })} ·{' '}
|
||||
{option.settingNameDisplay}
|
||||
{option.displayName}
|
||||
</p>
|
||||
{option.setting.description && (
|
||||
{option.description && (
|
||||
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
|
||||
{option.setting.description}
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
|
68
src/components/Settings/SettingsSectionsList.tsx
Normal file
68
src/components/Settings/SettingsSectionsList.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import decamelize from 'decamelize'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { shouldHideSetting } from 'lib/settings/settingsUtils'
|
||||
|
||||
interface SettingsSectionsListProps {
|
||||
searchParamTab: SettingsLevel
|
||||
scrollRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function SettingsSectionsList({
|
||||
searchParamTab,
|
||||
scrollRef,
|
||||
}: SettingsSectionsListProps) {
|
||||
const {
|
||||
settings: { context },
|
||||
} = useSettingsAuthContext()
|
||||
return (
|
||||
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||
{Object.entries(context)
|
||||
.filter(([_, categorySettings]) =>
|
||||
// Filter out categories that don't have any non-hidden settings
|
||||
Object.values(categorySettings).some(
|
||||
(setting: Setting) => !shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category]) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() =>
|
||||
scrollRef.current
|
||||
?.querySelector(`#category-${category}`)
|
||||
?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
{decamelize(category, { separator: ' ' })}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollRef.current?.querySelector(`#settings-resets`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
Resets
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollRef.current?.querySelector(`#settings-about`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
About
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -34,6 +34,15 @@ export function SettingsTabs({
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
)}
|
||||
<RadioGroup.Option value="keybindings">
|
||||
{({ checked }) => (
|
||||
<SettingsTabButton
|
||||
checked={checked}
|
||||
icon="keyboard"
|
||||
text="Keybindings"
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user