@adecore/ui 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/AccentSwatches.d.ts +15 -0
- package/dist/AccentSwatches.js +21 -0
- package/dist/Banner.d.ts +12 -0
- package/dist/Banner.js +18 -0
- package/dist/Button.d.ts +9 -0
- package/dist/Button.js +28 -0
- package/dist/ButtonGroup.d.ts +3 -0
- package/dist/ButtonGroup.js +11 -0
- package/dist/Checkbox.d.ts +11 -0
- package/dist/Checkbox.js +9 -0
- package/dist/ChoiceCards.d.ts +23 -0
- package/dist/ChoiceCards.js +21 -0
- package/dist/CloseButton.d.ts +15 -0
- package/dist/CloseButton.js +11 -0
- package/dist/ColorSwatch.d.ts +14 -0
- package/dist/ColorSwatch.js +28 -0
- package/dist/ColumnResizeHandle.d.ts +9 -0
- package/dist/ColumnResizeHandle.js +12 -0
- package/dist/DisabledReason.d.ts +6 -0
- package/dist/DisabledReason.js +8 -0
- package/dist/DockShell.d.ts +9 -0
- package/dist/DockShell.js +49 -0
- package/dist/EmptyState.d.ts +16 -0
- package/dist/EmptyState.js +11 -0
- package/dist/ErrorBoundary.d.ts +24 -0
- package/dist/ErrorBoundary.js +47 -0
- package/dist/Field.d.ts +17 -0
- package/dist/Field.js +48 -0
- package/dist/FileIcon.d.ts +8 -0
- package/dist/FileIcon.js +15 -0
- package/dist/Icon.d.ts +9 -0
- package/dist/Icon.js +12 -0
- package/dist/IconButton.d.ts +23 -0
- package/dist/IconButton.js +25 -0
- package/dist/IconPicker.d.ts +17 -0
- package/dist/IconPicker.js +93 -0
- package/dist/Input.d.ts +14 -0
- package/dist/Input.js +25 -0
- package/dist/Kbd.d.ts +23 -0
- package/dist/Kbd.js +20 -0
- package/dist/ListRow.d.ts +10 -0
- package/dist/ListRow.js +20 -0
- package/dist/Meter.d.ts +12 -0
- package/dist/Meter.js +7 -0
- package/dist/PanelEmpty.d.ts +16 -0
- package/dist/PanelEmpty.js +7 -0
- package/dist/PanelHeader.d.ts +6 -0
- package/dist/PanelHeader.js +20 -0
- package/dist/Pill.d.ts +27 -0
- package/dist/Pill.js +25 -0
- package/dist/ProjectSwitcher.d.ts +30 -0
- package/dist/ProjectSwitcher.js +18 -0
- package/dist/PromptDialog.d.ts +40 -0
- package/dist/PromptDialog.js +47 -0
- package/dist/SectionLabel.d.ts +3 -0
- package/dist/SectionLabel.js +16 -0
- package/dist/SegmentBar.d.ts +18 -0
- package/dist/SegmentBar.js +44 -0
- package/dist/Segmented.d.ts +17 -0
- package/dist/Segmented.js +7 -0
- package/dist/Select.d.ts +27 -0
- package/dist/Select.js +34 -0
- package/dist/Separator.d.ts +7 -0
- package/dist/Separator.js +8 -0
- package/dist/ShortcutHints.d.ts +1 -0
- package/dist/ShortcutHints.js +94 -0
- package/dist/Skeleton.d.ts +6 -0
- package/dist/Skeleton.js +6 -0
- package/dist/SlidingColumn.d.ts +19 -0
- package/dist/SlidingColumn.js +41 -0
- package/dist/Spinner.d.ts +6 -0
- package/dist/Spinner.js +7 -0
- package/dist/Stepper.d.ts +14 -0
- package/dist/Stepper.js +18 -0
- package/dist/Surface.d.ts +3 -0
- package/dist/Surface.js +16 -0
- package/dist/Switch.d.ts +10 -0
- package/dist/Switch.js +7 -0
- package/dist/TextMenu.d.ts +5 -0
- package/dist/TextMenu.js +31 -0
- package/dist/Tile.d.ts +11 -0
- package/dist/Tile.js +10 -0
- package/dist/Toasts.d.ts +9 -0
- package/dist/Toasts.js +58 -0
- package/dist/Tooltip.d.ts +16 -0
- package/dist/Tooltip.js +21 -0
- package/dist/UIProvider.d.ts +9 -0
- package/dist/UIProvider.js +26 -0
- package/dist/Waveform.d.ts +16 -0
- package/dist/Waveform.js +123 -0
- package/dist/Wipe.d.ts +12 -0
- package/dist/Wipe.js +47 -0
- package/dist/ZoomControls.d.ts +30 -0
- package/dist/ZoomControls.js +16 -0
- package/dist/class-name.d.ts +2 -0
- package/dist/class-name.js +3 -0
- package/dist/clipboard.d.ts +2 -0
- package/dist/clipboard.js +16 -0
- package/dist/context-menu/index.parts.d.ts +1 -0
- package/dist/context-menu/index.parts.js +2 -0
- package/dist/dialog/index.parts.d.ts +1 -0
- package/dist/dialog/index.parts.js +1 -0
- package/dist/dialog/parts.d.ts +36 -0
- package/dist/dialog/parts.js +55 -0
- package/dist/dialog-layer.d.ts +1 -0
- package/dist/dialog-layer.js +27 -0
- package/dist/error-boundary.d.ts +4 -0
- package/dist/error-boundary.js +34 -0
- package/dist/error-message.d.ts +1 -0
- package/dist/error-message.js +4 -0
- package/dist/field-context.d.ts +7 -0
- package/dist/field-context.js +4 -0
- package/dist/file-icon.d.ts +4 -0
- package/dist/file-icon.js +46 -0
- package/dist/floating.d.ts +5 -0
- package/dist/floating.js +6 -0
- package/dist/format/datetime.d.ts +11 -0
- package/dist/format/datetime.js +50 -0
- package/dist/format/duration.d.ts +5 -0
- package/dist/format/duration.js +76 -0
- package/dist/format/index.d.ts +6 -0
- package/dist/format/index.js +7 -0
- package/dist/format/locale.d.ts +17 -0
- package/dist/format/locale.js +105 -0
- package/dist/format/number.d.ts +8 -0
- package/dist/format/number.js +72 -0
- package/dist/format/regions.d.ts +6 -0
- package/dist/format/regions.js +15 -0
- package/dist/format/time-zone.d.ts +1 -0
- package/dist/format/time-zone.js +2 -0
- package/dist/icon-button-size.d.ts +3 -0
- package/dist/icon-button-size.js +3 -0
- package/dist/icon-picker.d.ts +23 -0
- package/dist/icon-picker.js +80 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +74 -0
- package/dist/lazy.d.ts +12 -0
- package/dist/lazy.js +104 -0
- package/dist/locales/en.json +59 -0
- package/dist/locales/nl.json +59 -0
- package/dist/locales.d.ts +4 -0
- package/dist/locales.js +17 -0
- package/dist/menu/index.parts.d.ts +1 -0
- package/dist/menu/index.parts.js +1 -0
- package/dist/menu/parts.d.ts +67 -0
- package/dist/menu/parts.js +113 -0
- package/dist/merge-refs.d.ts +2 -0
- package/dist/merge-refs.js +11 -0
- package/dist/modality.d.ts +1 -0
- package/dist/modality.js +29 -0
- package/dist/platform.d.ts +2 -0
- package/dist/platform.js +9 -0
- package/dist/popover/index.parts.d.ts +1 -0
- package/dist/popover/index.parts.js +1 -0
- package/dist/popover/parts.d.ts +26 -0
- package/dist/popover/parts.js +28 -0
- package/dist/prefetch.d.ts +21 -0
- package/dist/prefetch.js +99 -0
- package/dist/preview-card/index.parts.d.ts +1 -0
- package/dist/preview-card/index.parts.js +1 -0
- package/dist/project-switcher-row.d.ts +6 -0
- package/dist/project-switcher-row.js +16 -0
- package/dist/selection.d.ts +2 -0
- package/dist/selection.js +22 -0
- package/dist/seti-icons.d.ts +5 -0
- package/dist/seti-icons.js +445 -0
- package/dist/settings/ConfirmDialog.d.ts +9 -0
- package/dist/settings/ConfirmDialog.js +9 -0
- package/dist/settings/DetailHeader.d.ts +10 -0
- package/dist/settings/DetailHeader.js +6 -0
- package/dist/settings/MasterDetail.d.ts +24 -0
- package/dist/settings/MasterDetail.js +16 -0
- package/dist/settings/SettingsDialog.d.ts +46 -0
- package/dist/settings/SettingsDialog.js +96 -0
- package/dist/settings/SettingsRow.d.ts +22 -0
- package/dist/settings/SettingsRow.js +30 -0
- package/dist/settings/SettingsSection.d.ts +14 -0
- package/dist/settings/SettingsSection.js +8 -0
- package/dist/settings/index.d.ts +7 -0
- package/dist/settings/index.js +8 -0
- package/dist/settings/target.d.ts +7 -0
- package/dist/settings/target.js +3 -0
- package/dist/shortcut-hints.d.ts +38 -0
- package/dist/shortcut-hints.js +81 -0
- package/dist/shortcut.d.ts +32 -0
- package/dist/shortcut.js +137 -0
- package/dist/testing/dedupe.d.ts +1 -0
- package/dist/testing/dedupe.js +73 -0
- package/dist/testing/fake-source.d.ts +8 -0
- package/dist/testing/fake-source.js +15 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/theme.css +562 -0
- package/dist/toast-store.d.ts +38 -0
- package/dist/toast-store.js +61 -0
- package/dist/useAsyncAction.d.ts +8 -0
- package/dist/useAsyncAction.js +23 -0
- package/dist/useColumnResize.d.ts +14 -0
- package/dist/useColumnResize.js +49 -0
- package/dist/useContentSize.d.ts +5 -0
- package/dist/useContentSize.js +21 -0
- package/dist/useMeasuredWidth.d.ts +1 -0
- package/dist/useMeasuredWidth.js +6 -0
- package/dist/useNow.d.ts +3 -0
- package/dist/useNow.js +41 -0
- package/dist/wipe-split.d.ts +7 -0
- package/dist/wipe-split.js +20 -0
- package/dist/zoom.d.ts +3 -0
- package/dist/zoom.js +6 -0
- package/package.json +92 -0
- package/src/AccentSwatches.tsx +73 -0
- package/src/Banner.tsx +43 -0
- package/src/Button.tsx +55 -0
- package/src/ButtonGroup.tsx +14 -0
- package/src/Checkbox.tsx +44 -0
- package/src/ChoiceCards.tsx +108 -0
- package/src/CloseButton.tsx +21 -0
- package/src/ColorSwatch.tsx +41 -0
- package/src/ColumnResizeHandle.tsx +23 -0
- package/src/DisabledReason.tsx +23 -0
- package/src/DockShell.tsx +76 -0
- package/src/EmptyState.tsx +48 -0
- package/src/ErrorBoundary.tsx +116 -0
- package/src/Field.tsx +116 -0
- package/src/FileIcon.tsx +27 -0
- package/src/Icon.tsx +22 -0
- package/src/IconButton.tsx +84 -0
- package/src/IconPicker.tsx +257 -0
- package/src/Input.tsx +78 -0
- package/src/Kbd.tsx +67 -0
- package/src/ListRow.tsx +26 -0
- package/src/Meter.tsx +41 -0
- package/src/PanelEmpty.tsx +37 -0
- package/src/PanelHeader.tsx +31 -0
- package/src/Pill.tsx +61 -0
- package/src/ProjectSwitcher.tsx +127 -0
- package/src/PromptDialog.tsx +168 -0
- package/src/SectionLabel.tsx +20 -0
- package/src/SegmentBar.tsx +128 -0
- package/src/Segmented.tsx +51 -0
- package/src/Select.tsx +135 -0
- package/src/Separator.tsx +15 -0
- package/src/ShortcutHints.tsx +118 -0
- package/src/Skeleton.tsx +13 -0
- package/src/SlidingColumn.tsx +91 -0
- package/src/Spinner.tsx +32 -0
- package/src/Stepper.tsx +67 -0
- package/src/Surface.tsx +20 -0
- package/src/Switch.tsx +32 -0
- package/src/TextMenu.tsx +59 -0
- package/src/Tile.tsx +49 -0
- package/src/Toasts.tsx +151 -0
- package/src/Tooltip.tsx +70 -0
- package/src/UIProvider.tsx +41 -0
- package/src/Waveform.tsx +199 -0
- package/src/Wipe.tsx +99 -0
- package/src/ZoomControls.tsx +99 -0
- package/src/class-name.ts +8 -0
- package/src/clipboard.ts +17 -0
- package/src/context-menu/index.parts.ts +21 -0
- package/src/dialog/index.parts.ts +10 -0
- package/src/dialog/parts.tsx +112 -0
- package/src/dialog-layer.ts +31 -0
- package/src/error-boundary.ts +40 -0
- package/src/error-message.ts +5 -0
- package/src/field-context.ts +12 -0
- package/src/file-icon.ts +52 -0
- package/src/floating.ts +9 -0
- package/src/format/datetime.ts +63 -0
- package/src/format/duration.ts +83 -0
- package/src/format/index.ts +20 -0
- package/src/format/locale.ts +153 -0
- package/src/format/number.ts +87 -0
- package/src/format/regions.ts +20 -0
- package/src/format/time-zone.ts +2 -0
- package/src/icon-button-size.ts +6 -0
- package/src/icon-picker.ts +112 -0
- package/src/index.ts +103 -0
- package/src/lazy.tsx +124 -0
- package/src/locales/en.json +59 -0
- package/src/locales/nl.json +59 -0
- package/src/locales.ts +21 -0
- package/src/menu/index.parts.ts +20 -0
- package/src/menu/parts.tsx +296 -0
- package/src/merge-refs.ts +14 -0
- package/src/modality.ts +37 -0
- package/src/platform.ts +10 -0
- package/src/popover/index.parts.ts +8 -0
- package/src/popover/parts.tsx +119 -0
- package/src/prefetch.ts +113 -0
- package/src/preview-card/index.parts.ts +1 -0
- package/src/project-switcher-row.tsx +51 -0
- package/src/selection.ts +24 -0
- package/src/seti-icons.ts +451 -0
- package/src/settings/ConfirmDialog.tsx +30 -0
- package/src/settings/DetailHeader.tsx +25 -0
- package/src/settings/MasterDetail.tsx +67 -0
- package/src/settings/SettingsDialog.tsx +342 -0
- package/src/settings/SettingsRow.tsx +89 -0
- package/src/settings/SettingsSection.tsx +46 -0
- package/src/settings/index.ts +16 -0
- package/src/settings/target.ts +12 -0
- package/src/shortcut-hints.ts +117 -0
- package/src/shortcut.ts +170 -0
- package/src/testing/dedupe.ts +93 -0
- package/src/testing/fake-source.ts +21 -0
- package/src/testing/index.ts +3 -0
- package/src/theme.css +562 -0
- package/src/toast-store.ts +120 -0
- package/src/useAsyncAction.ts +32 -0
- package/src/useColumnResize.ts +75 -0
- package/src/useContentSize.ts +27 -0
- package/src/useMeasuredWidth.ts +7 -0
- package/src/useNow.ts +49 -0
- package/src/wipe-split.ts +24 -0
- package/src/zoom.ts +9 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PromptDialog } from '../PromptDialog.tsx';
|
|
2
|
+
|
|
3
|
+
export interface ConfirmDialogProps {
|
|
4
|
+
open: boolean;
|
|
5
|
+
onOpenChange(open: boolean): void;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
confirmLabel: string;
|
|
9
|
+
/* Closes the dialog once it resolves; a rejection stays on screen as the reason. */
|
|
10
|
+
onConfirm(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* One destructive step, asked before it happens, over whichever dialog it was opened from. */
|
|
14
|
+
export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel, onConfirm }: ConfirmDialogProps) {
|
|
15
|
+
return (
|
|
16
|
+
<PromptDialog
|
|
17
|
+
open={open}
|
|
18
|
+
nested
|
|
19
|
+
danger
|
|
20
|
+
title={title}
|
|
21
|
+
description={description}
|
|
22
|
+
confirmLabel={confirmLabel}
|
|
23
|
+
onConfirm={async () => {
|
|
24
|
+
await onConfirm();
|
|
25
|
+
onOpenChange(false);
|
|
26
|
+
}}
|
|
27
|
+
onOpenChange={onOpenChange}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
|
|
4
|
+
export interface DetailHeaderProps {
|
|
5
|
+
mark: ReactNode;
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle: ReactNode;
|
|
8
|
+
actions?: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
ref?: Ref<HTMLElement>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* The head of a detail: a mark, the name, a line under it, and the actions of the thing. */
|
|
14
|
+
export function DetailHeader({ mark, title, subtitle, actions, className, ref }: DetailHeaderProps) {
|
|
15
|
+
return (
|
|
16
|
+
<header ref={ref} className={clsx('flex min-w-0 flex-wrap items-start gap-3', className)}>
|
|
17
|
+
{mark}
|
|
18
|
+
<div className="min-w-0 grow basis-48">
|
|
19
|
+
<h3 className="truncate text-lg font-semibold text-text">{title}</h3>
|
|
20
|
+
<div className="flex min-w-0 items-center gap-1.5 text-xs break-words text-text-muted">{subtitle}</div>
|
|
21
|
+
</div>
|
|
22
|
+
{actions}
|
|
23
|
+
</header>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
|
|
4
|
+
const LIST_WIDTHS = { 280: 'w-70', 320: 'w-80', 340: 'w-85' } as const;
|
|
5
|
+
|
|
6
|
+
export interface MasterDetailProps {
|
|
7
|
+
/* The column on the left, usually `MasterItem`s under their own headers. */
|
|
8
|
+
list: ReactNode;
|
|
9
|
+
listWidth: keyof typeof LIST_WIDTHS;
|
|
10
|
+
/* Names the list for a screen reader. */
|
|
11
|
+
listLabel: string;
|
|
12
|
+
detail: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
ref?: Ref<HTMLDivElement>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* A list beside the detail of what is picked in it, for a pane of many things of one kind (accounts,
|
|
19
|
+
* devices, shortcut categories). Each side scrolls on its own, and the detail takes the rest of the
|
|
20
|
+
* width, like a single-column pane. Its section must be `split`, so the dialog hands the pane its whole height
|
|
21
|
+
* instead of a padded scrolling column.
|
|
22
|
+
*/
|
|
23
|
+
export function MasterDetail({ list, listWidth, listLabel, detail, className, ref }: MasterDetailProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div ref={ref} className={clsx('flex min-h-0 min-w-0 grow border-t border-border max-[640px]:flex-col', className)}>
|
|
26
|
+
<nav
|
|
27
|
+
aria-label={listLabel}
|
|
28
|
+
className={clsx(
|
|
29
|
+
'flex shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border p-2 max-[640px]:max-h-60 max-[640px]:w-auto max-[640px]:border-r-0 max-[640px]:border-b',
|
|
30
|
+
LIST_WIDTHS[listWidth]
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{list}
|
|
34
|
+
</nav>
|
|
35
|
+
<div className="min-h-0 min-w-0 grow overflow-y-auto">
|
|
36
|
+
<div className="flex min-w-0 flex-col gap-7 px-8 pt-6 pb-10 max-[960px]:px-4">{detail}</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MasterItemProps {
|
|
43
|
+
selected: boolean;
|
|
44
|
+
onSelect(): void;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
className?: string;
|
|
47
|
+
ref?: Ref<HTMLButtonElement>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* One row of the list. The picked one is lifted a shade. */
|
|
51
|
+
export function MasterItem({ selected, onSelect, children, className, ref }: MasterItemProps) {
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
ref={ref}
|
|
55
|
+
type="button"
|
|
56
|
+
aria-current={selected ? 'true' : undefined}
|
|
57
|
+
className={clsx(
|
|
58
|
+
'flex w-full min-w-0 shrink-0 items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-text focus-visible:-outline-offset-2',
|
|
59
|
+
selected ? 'bg-text/5' : 'hover:bg-surface-hover',
|
|
60
|
+
className
|
|
61
|
+
)}
|
|
62
|
+
onClick={onSelect}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Suspense, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { Tabs } from '@base-ui-components/react/tabs';
|
|
5
|
+
import { Search, X, type LucideIcon } from 'lucide-react';
|
|
6
|
+
import { CloseButton } from '../CloseButton.tsx';
|
|
7
|
+
import { DialogDescription, DialogPopup, DialogRoot, DialogTitle } from '../dialog/parts.tsx';
|
|
8
|
+
import { ErrorBoundary } from '../ErrorBoundary.tsx';
|
|
9
|
+
import { Icon } from '../Icon.tsx';
|
|
10
|
+
import { IconButton } from '../IconButton.tsx';
|
|
11
|
+
import { Input } from '../Input.tsx';
|
|
12
|
+
import { Select } from '../Select.tsx';
|
|
13
|
+
import { SettingsTargetContext } from './target.ts';
|
|
14
|
+
|
|
15
|
+
export interface SettingsSectionEntry {
|
|
16
|
+
id: string;
|
|
17
|
+
icon: LucideIcon;
|
|
18
|
+
label: string;
|
|
19
|
+
/* One line under the pane title that says what the pane is about. */
|
|
20
|
+
description: string;
|
|
21
|
+
/* Rendered in a Suspense, so a lazy component loads when its section is opened. */
|
|
22
|
+
pane: ComponentType;
|
|
23
|
+
/* A list beside a detail that scrolls on its own (`MasterDetail`), instead of one padded column. */
|
|
24
|
+
split?: boolean;
|
|
25
|
+
/* A block above the pane that runs to its edges, with the title bar floating over its top. Loads with
|
|
26
|
+
the pane; a `split` section draws none. */
|
|
27
|
+
hero?: ComponentType;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SettingsGroupEntry {
|
|
31
|
+
/* The first group usually goes without one. */
|
|
32
|
+
label: string | null;
|
|
33
|
+
sections: readonly SettingsSectionEntry[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SettingsSearchResult {
|
|
37
|
+
section: string;
|
|
38
|
+
/* The row to reveal (a `SettingsRow` `searchId`), or null for a result that is the pane itself. */
|
|
39
|
+
id: string | null;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SettingsSearch {
|
|
44
|
+
find(query: string): readonly SettingsSearchResult[];
|
|
45
|
+
/* The shortcut that focuses the field, as it is printed. */
|
|
46
|
+
hint?: string;
|
|
47
|
+
/* Focuses and selects the field each time it grows. */
|
|
48
|
+
focusAt?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SettingsDialogProps {
|
|
52
|
+
open: boolean;
|
|
53
|
+
onOpenChange(open: boolean): void;
|
|
54
|
+
section: string;
|
|
55
|
+
/* A tab or a section picked, or a search result with the row it leads to. */
|
|
56
|
+
onNavigate(next: { section: string; target?: string | null }): void;
|
|
57
|
+
groups: readonly SettingsGroupEntry[];
|
|
58
|
+
/* At the foot of the navigation, under the groups. */
|
|
59
|
+
footer?: readonly SettingsSectionEntry[];
|
|
60
|
+
/* Under the footer sections, past a hairline: a section whose tab the app draws itself (a `Tabs.Tab`
|
|
61
|
+
with the section's id as its value), such as an account. */
|
|
62
|
+
account?: { section: SettingsSectionEntry; tab: ReactNode };
|
|
63
|
+
search?: SettingsSearch;
|
|
64
|
+
/* The row a search result jumped to, lit until `onTargetShown`. */
|
|
65
|
+
target?: string | null;
|
|
66
|
+
onTargetShown?(): void;
|
|
67
|
+
/* Extra on the popup. */
|
|
68
|
+
className?: string;
|
|
69
|
+
ref?: Ref<HTMLDivElement>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* How far a pane with a hero scrolls before the title bar over it is filled in. */
|
|
73
|
+
const BAR_FILL_DISTANCE = 70;
|
|
74
|
+
|
|
75
|
+
const NAV_ITEM =
|
|
76
|
+
'flex h-8 min-w-0 shrink-0 items-center gap-2.5 rounded-md px-2.5 text-sm focus-visible:-outline-offset-2 text-text-muted hover:bg-surface-hover hover:text-text data-active:bg-surface-active data-active:text-text';
|
|
77
|
+
|
|
78
|
+
function NavTab({ section }: { section: SettingsSectionEntry }) {
|
|
79
|
+
return (
|
|
80
|
+
<Tabs.Tab value={section.id} className={NAV_ITEM}>
|
|
81
|
+
<Icon icon={section.icon} size={16} className="shrink-0 text-text-faint" />
|
|
82
|
+
<span className="truncate">{section.label}</span>
|
|
83
|
+
</Tabs.Tab>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function SettingsNav({ groups, footer, account }: Pick<SettingsDialogProps, 'groups' | 'footer' | 'account'>) {
|
|
88
|
+
const { t } = useTranslation('ui');
|
|
89
|
+
return (
|
|
90
|
+
<Tabs.List aria-label={t('settings.sections')} activateOnFocus className="flex min-h-0 grow flex-col">
|
|
91
|
+
<div className="flex min-h-0 flex-col gap-3.5 overflow-y-auto">
|
|
92
|
+
{groups.map((group) => (
|
|
93
|
+
<div key={group.sections[0]!.id} className="flex flex-col gap-0.5">
|
|
94
|
+
{group.label !== null && <span className="px-2.5 pt-2.5 pb-1 text-xs font-medium text-text-faint">{group.label}</span>}
|
|
95
|
+
{group.sections.map((section) => (
|
|
96
|
+
<NavTab key={section.id} section={section} />
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
{(footer?.length || account) && (
|
|
102
|
+
<div className="mt-auto flex flex-col gap-3 pt-3.5">
|
|
103
|
+
{footer?.map((section) => (
|
|
104
|
+
<NavTab key={section.id} section={section} />
|
|
105
|
+
))}
|
|
106
|
+
{account && <div className="-mx-3 -mb-1 border-t border-border px-3 pt-3">{account.tab}</div>}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</Tabs.List>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface SearchFieldProps {
|
|
114
|
+
search: SettingsSearch;
|
|
115
|
+
query: string;
|
|
116
|
+
onQuery(query: string): void;
|
|
117
|
+
onPick(result: SettingsSearchResult): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function SearchField({ search, query, onQuery, onPick }: SearchFieldProps) {
|
|
121
|
+
const { t } = useTranslation('ui');
|
|
122
|
+
const input = useRef<HTMLInputElement>(null);
|
|
123
|
+
const focusAt = search.focusAt ?? 0;
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (focusAt > 0) {
|
|
127
|
+
input.current?.focus();
|
|
128
|
+
input.current?.select();
|
|
129
|
+
}
|
|
130
|
+
}, [focusAt]);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="relative shrink-0">
|
|
134
|
+
<Icon icon={Search} size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-text-faint" />
|
|
135
|
+
<Input
|
|
136
|
+
ref={input}
|
|
137
|
+
className="bg-surface-sunken pr-12 pl-8 text-xs"
|
|
138
|
+
placeholder={t('settings.search.placeholder')}
|
|
139
|
+
aria-label={t('settings.search.placeholder')}
|
|
140
|
+
value={query}
|
|
141
|
+
spellCheck={false}
|
|
142
|
+
autoComplete="off"
|
|
143
|
+
onChange={(event) => onQuery(event.target.value)}
|
|
144
|
+
onKeyDown={(event) => {
|
|
145
|
+
// Escape clears the search first; the dialog only closes on an empty field.
|
|
146
|
+
if (event.key === 'Escape' && query) {
|
|
147
|
+
event.stopPropagation();
|
|
148
|
+
onQuery('');
|
|
149
|
+
}
|
|
150
|
+
if (event.key === 'Enter') {
|
|
151
|
+
const first = search.find(query)[0];
|
|
152
|
+
if (first) {
|
|
153
|
+
onPick(first);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
{query ? (
|
|
159
|
+
<IconButton
|
|
160
|
+
icon={X}
|
|
161
|
+
size="sm"
|
|
162
|
+
label={t('settings.search.clear')}
|
|
163
|
+
tooltip={false}
|
|
164
|
+
className="absolute top-1/2 right-1 -translate-y-1/2"
|
|
165
|
+
onClick={() => onQuery('')}
|
|
166
|
+
/>
|
|
167
|
+
) : (
|
|
168
|
+
search.hint && (
|
|
169
|
+
<span className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-xs text-text-faint" aria-hidden>
|
|
170
|
+
{search.hint}
|
|
171
|
+
</span>
|
|
172
|
+
)
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface SearchResultsProps {
|
|
179
|
+
search: SettingsSearch;
|
|
180
|
+
query: string;
|
|
181
|
+
labelOf(section: string): string;
|
|
182
|
+
onPick(result: SettingsSearchResult): void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* In place of the navigation while a query is typed: the panes and rows that match, each a jump to where it lives. */
|
|
186
|
+
function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
|
|
187
|
+
const { t, i18n } = useTranslation('ui');
|
|
188
|
+
// The language is a dependency the linter cannot see; every result reads its words off i18next.
|
|
189
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
190
|
+
const results = useMemo(() => search.find(query), [search.find, query, i18n.language]);
|
|
191
|
+
|
|
192
|
+
if (results.length === 0) {
|
|
193
|
+
return <p className="px-2.5 py-1 text-xs break-words text-text-muted">{t('settings.search.none', { query: query.trim() })}</p>;
|
|
194
|
+
}
|
|
195
|
+
return (
|
|
196
|
+
<ul className="flex min-h-0 flex-col gap-0.5 overflow-y-auto" aria-label={t('settings.search.results')}>
|
|
197
|
+
{results.map((result) => (
|
|
198
|
+
<li key={`${result.section}:${result.id ?? ''}`}>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className="flex w-full min-w-0 flex-col rounded-md px-2.5 py-1.5 text-left hover:bg-surface-hover"
|
|
202
|
+
onClick={() => onPick(result)}
|
|
203
|
+
>
|
|
204
|
+
<span className="truncate text-sm text-text">{result.label}</span>
|
|
205
|
+
<span className="truncate text-xs text-text-faint">{result.id === null ? t('settings.search.pane') : labelOf(result.section)}</span>
|
|
206
|
+
</button>
|
|
207
|
+
</li>
|
|
208
|
+
))}
|
|
209
|
+
</ul>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Sections on the left, one pane on the right. A pane mounts only while its section is open. */
|
|
214
|
+
export function SettingsDialog({
|
|
215
|
+
open,
|
|
216
|
+
onOpenChange,
|
|
217
|
+
section,
|
|
218
|
+
onNavigate,
|
|
219
|
+
groups,
|
|
220
|
+
footer = [],
|
|
221
|
+
account,
|
|
222
|
+
search,
|
|
223
|
+
target = null,
|
|
224
|
+
onTargetShown,
|
|
225
|
+
className,
|
|
226
|
+
ref
|
|
227
|
+
}: SettingsDialogProps) {
|
|
228
|
+
const { t } = useTranslation('ui');
|
|
229
|
+
const [query, setQuery] = useState('');
|
|
230
|
+
const sections = [...groups.flatMap((group) => group.sections), ...footer, ...(account ? [account.section] : [])];
|
|
231
|
+
const meta = sections.find((entry) => entry.id === section) ?? sections[0]!;
|
|
232
|
+
const items = sections.map((entry) => ({ value: entry.id, label: entry.label }));
|
|
233
|
+
const searching = search !== undefined && query.trim() !== '';
|
|
234
|
+
const floating = meta.hero !== undefined && !meta.split;
|
|
235
|
+
const barFill = useRef<HTMLDivElement>(null);
|
|
236
|
+
// The latest callback behind a stable one, so a lit row's timer does not start over on every render.
|
|
237
|
+
const latestShown = useRef(onTargetShown);
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
latestShown.current = onTargetShown;
|
|
240
|
+
});
|
|
241
|
+
const targetValue = useMemo(() => ({ target, shown: () => latestShown.current?.() }), [target]);
|
|
242
|
+
|
|
243
|
+
const labelOf = (id: string): string => sections.find((entry) => entry.id === id)?.label ?? id;
|
|
244
|
+
const pick = (result: SettingsSearchResult): void => onNavigate({ section: result.section, target: result.id });
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<DialogRoot
|
|
248
|
+
open={open}
|
|
249
|
+
onOpenChange={(next) => {
|
|
250
|
+
onOpenChange(next);
|
|
251
|
+
if (!next) {
|
|
252
|
+
setQuery('');
|
|
253
|
+
}
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
{/* The width steps down with the viewport: a narrower navigation on a tablet, and a menu of
|
|
257
|
+
sections instead of the column where even that leaves the panes too little room. */}
|
|
258
|
+
<DialogPopup ref={ref} className={clsx('flex h-[760px] w-[1200px]', className)}>
|
|
259
|
+
<Tabs.Root
|
|
260
|
+
value={meta.id}
|
|
261
|
+
onValueChange={(value) => onNavigate({ section: value as string })}
|
|
262
|
+
orientation="vertical"
|
|
263
|
+
className="flex min-h-0 min-w-0 grow max-[640px]:flex-col"
|
|
264
|
+
>
|
|
265
|
+
<div className="flex w-58 shrink-0 flex-col gap-3.5 border-r border-border bg-surface bg-clip-padding px-3 py-4 max-[960px]:w-48 max-[640px]:w-auto max-[640px]:flex-row max-[640px]:items-center max-[640px]:border-r-0 max-[640px]:border-b">
|
|
266
|
+
<DialogTitle className="px-2.5 pt-1 max-[640px]:pt-0">{t('settings.title')}</DialogTitle>
|
|
267
|
+
<div className="contents max-[640px]:hidden">
|
|
268
|
+
{search && <SearchField search={search} query={query} onQuery={setQuery} onPick={pick} />}
|
|
269
|
+
{searching ? (
|
|
270
|
+
<SearchResults search={search} query={query} labelOf={labelOf} onPick={pick} />
|
|
271
|
+
) : (
|
|
272
|
+
<SettingsNav groups={groups} footer={footer} account={account} />
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
<div className="min-w-0 grow min-[641px]:hidden">
|
|
276
|
+
<Select value={meta.id} items={items} onValueChange={(value) => onNavigate({ section: value })} label={t('settings.section')} />
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
{/* The header sits outside the panels: inside one it remounts on every section
|
|
280
|
+
change, which throws the keyboard's focus away mid-arrow-key. */}
|
|
281
|
+
<div className="relative flex min-h-0 min-w-0 grow flex-col">
|
|
282
|
+
<div
|
|
283
|
+
className={clsx('flex min-w-0 items-start gap-4 px-8 pt-5.5 pb-4.5 max-[960px]:px-4', floating && 'absolute inset-x-0 top-0 z-10')}
|
|
284
|
+
>
|
|
285
|
+
{floating && (
|
|
286
|
+
// Keyed by section, so the next pane opens under a clear bar again.
|
|
287
|
+
<div
|
|
288
|
+
key={meta.id}
|
|
289
|
+
ref={barFill}
|
|
290
|
+
className="absolute inset-0 -z-10 border-b border-border bg-surface-raised opacity-0"
|
|
291
|
+
aria-hidden
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
<div className="min-w-0 grow">
|
|
295
|
+
<h2 className="text-lg font-semibold text-text">{meta.label}</h2>
|
|
296
|
+
<p className="mt-0.5 text-xs break-words text-text-muted">{meta.description}</p>
|
|
297
|
+
</div>
|
|
298
|
+
<CloseButton label={t('settings.close')} dialog />
|
|
299
|
+
</div>
|
|
300
|
+
<SettingsTargetContext value={targetValue}>
|
|
301
|
+
{sections.map((entry) => {
|
|
302
|
+
const Pane = entry.pane;
|
|
303
|
+
const Hero = entry.split ? undefined : entry.hero;
|
|
304
|
+
return (
|
|
305
|
+
<Tabs.Panel
|
|
306
|
+
key={entry.id}
|
|
307
|
+
value={entry.id}
|
|
308
|
+
keepMounted={false}
|
|
309
|
+
className={clsx(
|
|
310
|
+
'flex min-h-0 min-w-0 grow flex-col outline-none',
|
|
311
|
+
// A split pane scrolls each of its sides itself.
|
|
312
|
+
!entry.split && 'gap-7 overflow-y-auto px-8 pb-10 max-[960px]:px-4',
|
|
313
|
+
// Under a hero the bar's fill marks the edge; a fade would wipe the hero out behind the clear bar.
|
|
314
|
+
!entry.split && !Hero && 'scroll-fade-top pt-1'
|
|
315
|
+
)}
|
|
316
|
+
onScroll={(event) => {
|
|
317
|
+
const top = event.currentTarget.scrollTop;
|
|
318
|
+
event.currentTarget.toggleAttribute('data-fade-start', top > 0);
|
|
319
|
+
barFill.current?.style.setProperty('opacity', String(Math.min(top / BAR_FILL_DISTANCE, 1)));
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
<ErrorBoundary label={t('settings.failed')} className="min-h-0 grow">
|
|
323
|
+
<Suspense fallback={null}>
|
|
324
|
+
{Hero && (
|
|
325
|
+
<div className="-mx-8 shrink-0 max-[960px]:-mx-4">
|
|
326
|
+
<Hero />
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
<Pane />
|
|
330
|
+
</Suspense>
|
|
331
|
+
</ErrorBoundary>
|
|
332
|
+
</Tabs.Panel>
|
|
333
|
+
);
|
|
334
|
+
})}
|
|
335
|
+
</SettingsTargetContext>
|
|
336
|
+
</div>
|
|
337
|
+
</Tabs.Root>
|
|
338
|
+
<DialogDescription className="sr-only">{meta.description}</DialogDescription>
|
|
339
|
+
</DialogPopup>
|
|
340
|
+
</DialogRoot>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import type { LucideIcon } from 'lucide-react';
|
|
4
|
+
import { Icon } from '../Icon.tsx';
|
|
5
|
+
import { mergeRefs } from '../merge-refs.ts';
|
|
6
|
+
import { useSettingsTarget } from './target.ts';
|
|
7
|
+
|
|
8
|
+
/* How long a row a search result jumped to stays lit. */
|
|
9
|
+
const TARGET_MS = 1600;
|
|
10
|
+
|
|
11
|
+
export interface SettingsRowProps {
|
|
12
|
+
label: ReactNode;
|
|
13
|
+
description?: ReactNode;
|
|
14
|
+
/* The control, aligned right; it carries its own accessible name. */
|
|
15
|
+
control?: ReactNode;
|
|
16
|
+
/* Content under the label line that needs the whole width, like a list of swatches. */
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
/* Where the row only shows state, its whole line reads a shade softer. */
|
|
19
|
+
muted?: boolean;
|
|
20
|
+
/* Before the label, held to its first line (`TopIcon`) or a tile of its own. */
|
|
21
|
+
leading?: ReactNode;
|
|
22
|
+
/* A row that only makes sense under the one above it steps in. */
|
|
23
|
+
indent?: boolean;
|
|
24
|
+
/* The id a search result names to lead here. */
|
|
25
|
+
searchId?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
ref?: Ref<HTMLDivElement>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TopIconProps {
|
|
31
|
+
icon: LucideIcon;
|
|
32
|
+
size?: number;
|
|
33
|
+
className?: string;
|
|
34
|
+
ref?: Ref<HTMLSpanElement>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* An icon beside text that may wrap: the box is one line of `text-sm` high, so the icon sits on the first line. */
|
|
38
|
+
export function TopIcon({ icon, size = 16, className, ref }: TopIconProps) {
|
|
39
|
+
return (
|
|
40
|
+
<span ref={ref} className="flex h-5.5 shrink-0 items-center">
|
|
41
|
+
<Icon icon={icon} size={size} className={className} />
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* One setting: what it is on the left, the control on the right. Where the two do not fit side by
|
|
48
|
+
* side (a tablet, a narrow window) the control wraps under the label instead of widening the dialog.
|
|
49
|
+
*/
|
|
50
|
+
export function SettingsRow({ label, description, control, children, muted = false, leading, indent = false, searchId, className, ref }: SettingsRowProps) {
|
|
51
|
+
const row = useRef<HTMLDivElement>(null);
|
|
52
|
+
const { target, shown } = useSettingsTarget();
|
|
53
|
+
const targeted = searchId !== undefined && target === searchId;
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!targeted) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
row.current?.scrollIntoView({ block: 'center' });
|
|
60
|
+
const timer = window.setTimeout(shown, TARGET_MS);
|
|
61
|
+
return () => window.clearTimeout(timer);
|
|
62
|
+
}, [targeted, shown]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
ref={mergeRefs(row, ref)}
|
|
67
|
+
data-settings-row={searchId}
|
|
68
|
+
className={clsx(
|
|
69
|
+
'flex min-w-0 flex-col gap-3 pr-4.5 transition-colors duration-500 first:rounded-t-xl last:rounded-b-xl',
|
|
70
|
+
indent ? 'pl-14.5' : 'pl-4.5',
|
|
71
|
+
description || children || leading ? 'py-3.5' : 'py-3',
|
|
72
|
+
targeted && 'bg-accent-soft duration-0',
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-5 gap-y-2">
|
|
77
|
+
<div className="flex min-w-0 grow basis-60 items-start gap-3">
|
|
78
|
+
{leading}
|
|
79
|
+
<div className="min-w-0 grow">
|
|
80
|
+
<div className={clsx('text-sm break-words', muted ? 'text-text-muted' : 'text-text')}>{label}</div>
|
|
81
|
+
{description && <div className="mt-0.5 text-xs break-words text-text-muted">{description}</div>}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
{control && <div className="ml-auto flex max-w-full min-w-0 flex-wrap items-center justify-end gap-2">{control}</div>}
|
|
85
|
+
</div>
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import type { LucideIcon } from 'lucide-react';
|
|
4
|
+
import { Icon } from '../Icon.tsx';
|
|
5
|
+
|
|
6
|
+
export interface SettingsSectionProps {
|
|
7
|
+
title?: string;
|
|
8
|
+
description?: ReactNode;
|
|
9
|
+
/* Drawn before the title, muted, for a pane whose sections are kinds of thing. */
|
|
10
|
+
icon?: LucideIcon;
|
|
11
|
+
/* A tag on the header line, after the action, such as where the settings apply. */
|
|
12
|
+
tag?: ReactNode;
|
|
13
|
+
/* A small control on the header line, aligned right. */
|
|
14
|
+
action?: ReactNode;
|
|
15
|
+
/* A note under the card, in the faint color. */
|
|
16
|
+
footer?: ReactNode;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
ref?: Ref<HTMLElement>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* A titled group of rows in one card; rows divide themselves with a hairline. Without a title it is the card alone. */
|
|
23
|
+
export function SettingsSection({ title, description, icon, tag, action, footer, children, className, ref }: SettingsSectionProps) {
|
|
24
|
+
const hasHeader = title !== undefined || description !== undefined || tag !== undefined || action !== undefined;
|
|
25
|
+
return (
|
|
26
|
+
<section ref={ref} className={clsx('flex min-w-0 flex-col gap-2.5', className)} aria-label={title}>
|
|
27
|
+
{hasHeader && (
|
|
28
|
+
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-2">
|
|
29
|
+
<div className="min-w-0 grow basis-48">
|
|
30
|
+
{title !== undefined && (
|
|
31
|
+
<h3 className="flex items-center gap-2 text-sm font-medium text-text">
|
|
32
|
+
{icon && <Icon icon={icon} size={14} className="shrink-0 text-text-faint" />}
|
|
33
|
+
{title}
|
|
34
|
+
</h3>
|
|
35
|
+
)}
|
|
36
|
+
{description !== undefined && <p className="mt-0.5 text-xs break-words text-text-muted">{description}</p>}
|
|
37
|
+
</div>
|
|
38
|
+
{action}
|
|
39
|
+
{tag}
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
<div className="min-w-0 divide-y divide-border rounded-xl border border-border bg-surface bg-clip-padding">{children}</div>
|
|
43
|
+
{footer && <p className="text-xs text-text-faint">{footer}</p>}
|
|
44
|
+
</section>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* The settings dialog and the parts a pane is built from. */
|
|
2
|
+
|
|
3
|
+
export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog.tsx';
|
|
4
|
+
export { DetailHeader, type DetailHeaderProps } from './DetailHeader.tsx';
|
|
5
|
+
export { MasterDetail, MasterItem, type MasterDetailProps, type MasterItemProps } from './MasterDetail.tsx';
|
|
6
|
+
export {
|
|
7
|
+
SettingsDialog,
|
|
8
|
+
type SettingsDialogProps,
|
|
9
|
+
type SettingsGroupEntry,
|
|
10
|
+
type SettingsSearch,
|
|
11
|
+
type SettingsSearchResult,
|
|
12
|
+
type SettingsSectionEntry
|
|
13
|
+
} from './SettingsDialog.tsx';
|
|
14
|
+
export { SettingsRow, TopIcon, type SettingsRowProps, type TopIconProps } from './SettingsRow.tsx';
|
|
15
|
+
export { SettingsSection, type SettingsSectionProps } from './SettingsSection.tsx';
|
|
16
|
+
export { useSettingsTarget } from './target.ts';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SettingsTarget {
|
|
4
|
+
/* The row a search result jumped to, by its `searchId`. */
|
|
5
|
+
target: string | null;
|
|
6
|
+
/* Called once the row was lit long enough, so it is not lit again on the next render. */
|
|
7
|
+
shown(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SettingsTargetContext = createContext<SettingsTarget>({ target: null, shown: () => {} });
|
|
11
|
+
|
|
12
|
+
export const useSettingsTarget = (): SettingsTarget => useContext(SettingsTargetContext);
|