@adecore/ui 0.39.0 → 0.40.1
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/dist/AccentSwatches.js +2 -3
- package/dist/Banner.js +1 -6
- package/dist/ChoiceCards.js +0 -5
- package/dist/ColorSwatch.js +1 -4
- package/dist/ColorWheelPicker.js +3 -6
- package/dist/DockShell.js +3 -5
- package/dist/EmptyState.js +0 -1
- package/dist/ErrorBoundary.js +1 -5
- package/dist/Field.js +2 -6
- package/dist/FileIcon.js +2 -4
- package/dist/Icon.js +1 -2
- package/dist/IconButton.js +1 -1
- package/dist/IconPicker.js +3 -7
- package/dist/Input.js +1 -2
- package/dist/Kbd.js +1 -3
- package/dist/LazyBoundary.js +2 -3
- package/dist/Meter.js +0 -1
- package/dist/MiddleTruncate.js +2 -6
- package/dist/PanelEmpty.js +0 -1
- package/dist/PanelHeader.js +1 -4
- package/dist/Pill.js +1 -6
- package/dist/ProjectSwitcher.js +1 -1
- package/dist/PromptDialog.js +5 -8
- package/dist/RelativeTime.js +1 -1
- package/dist/SegmentBar.js +1 -2
- package/dist/Segmented.js +2 -5
- package/dist/Select.js +1 -4
- package/dist/ShortcutHints.js +2 -3
- package/dist/Slider.js +0 -1
- package/dist/SlidingColumn.js +2 -4
- package/dist/SplitButton.js +1 -5
- package/dist/TextMenu.js +1 -5
- package/dist/Tile.js +0 -4
- package/dist/Toasts.js +1 -9
- package/dist/Tooltip.js +1 -1
- package/dist/UIProvider.js +0 -5
- package/dist/Waveform.js +4 -2
- package/dist/Wipe.js +1 -6
- package/dist/ZoomControls.js +0 -1
- package/dist/clipboard.js +2 -5
- package/dist/combobox/parts.js +3 -9
- package/dist/command-palette/parts.js +5 -14
- package/dist/dialog/parts.js +5 -10
- package/dist/dialog-layer.js +2 -3
- package/dist/error-boundary.js +4 -20
- package/dist/file-icon.js +4 -9
- package/dist/file-tree/empty-lines.js +2 -8
- package/dist/file-tree/use-empty-lines.js +1 -5
- package/dist/file-tree/use-slots.js +6 -10
- package/dist/format/datetime.js +32 -29
- package/dist/format/duration.js +2 -10
- package/dist/format/locale.js +19 -39
- package/dist/format/number.js +9 -22
- package/dist/format/regions.js +4 -7
- package/dist/icon-picker.js +5 -14
- package/dist/key-value-list/parts.js +1 -5
- package/dist/lazy.js +4 -19
- package/dist/locales.js +2 -6
- package/dist/menu/parts.js +61 -133
- package/dist/middle-truncate.js +2 -3
- package/dist/placement.js +0 -1
- package/dist/popover/parts.js +1 -5
- package/dist/popup-layer.js +2 -4
- package/dist/prefetch.js +2 -5
- package/dist/selection.js +1 -5
- package/dist/settings/MasterDetail.js +1 -7
- package/dist/settings/SettingsDialog.js +1 -2
- package/dist/settings/SettingsRow.js +2 -5
- package/dist/settings/SettingsSection.js +1 -1
- package/dist/shortcut-hints.js +1 -5
- package/dist/shortcut.js +1 -2
- package/dist/tabs/overflow.js +1 -6
- package/dist/tabs/parts.js +69 -95
- package/dist/testing/dedupe.js +5 -7
- package/dist/tint.js +3 -5
- package/dist/toast-store.js +2 -10
- package/dist/tree/use-tree-shift.js +2 -6
- package/dist/useColumnResize.js +9 -10
- package/dist/useNow.js +2 -9
- package/dist/workspace/DropPreview.js +1 -5
- package/dist/workspace/preview.js +4 -9
- package/package.json +10 -10
- package/src/AccentSwatches.tsx +9 -3
- package/src/Banner.tsx +5 -11
- package/src/Button.tsx +4 -0
- package/src/Checkbox.tsx +3 -0
- package/src/ChoiceCards.tsx +12 -11
- package/src/CloseButton.tsx +1 -0
- package/src/ColorSwatch.tsx +7 -8
- package/src/ColorWheelPicker.tsx +28 -12
- package/src/ColumnResizeHandle.tsx +1 -0
- package/src/CopyButton.tsx +6 -0
- package/src/DisabledReason.tsx +1 -0
- package/src/DockShell.tsx +11 -10
- package/src/EmptyState.tsx +10 -6
- package/src/ErrorBoundary.tsx +13 -10
- package/src/Field.tsx +11 -13
- package/src/FileIcon.tsx +4 -4
- package/src/Icon.tsx +2 -4
- package/src/IconButton.tsx +14 -5
- package/src/IconPicker.tsx +30 -15
- package/src/Input.tsx +11 -9
- package/src/Kbd.tsx +8 -7
- package/src/LazyBoundary.tsx +3 -3
- package/src/Meter.tsx +7 -2
- package/src/MiddleTruncate.tsx +15 -6
- package/src/PanelEmpty.tsx +9 -7
- package/src/PanelHeader.tsx +2 -5
- package/src/Pill.tsx +7 -8
- package/src/ProjectSwitcher.tsx +16 -6
- package/src/PromptDialog.tsx +31 -26
- package/src/RelativeTime.tsx +6 -1
- package/src/SegmentBar.tsx +22 -11
- package/src/Segmented.tsx +16 -11
- package/src/Select.tsx +7 -4
- package/src/SelectSearch.tsx +3 -0
- package/src/ShortcutHints.tsx +24 -3
- package/src/Skeleton.tsx +1 -0
- package/src/Slider.tsx +4 -1
- package/src/SlidingColumn.tsx +18 -14
- package/src/Spinner.tsx +1 -0
- package/src/SplitButton.tsx +8 -8
- package/src/Stepper.tsx +6 -0
- package/src/Switch.tsx +2 -0
- package/src/TextMenu.tsx +2 -5
- package/src/Tile.tsx +2 -6
- package/src/Toasts.tsx +8 -12
- package/src/Tooltip.tsx +10 -5
- package/src/UIProvider.tsx +7 -7
- package/src/Waveform.tsx +41 -12
- package/src/Wipe.tsx +5 -6
- package/src/ZoomControls.tsx +5 -2
- package/src/clipboard.ts +4 -5
- package/src/combobox/parts.tsx +18 -14
- package/src/command-palette/context.ts +2 -0
- package/src/command-palette/index.parts.ts +1 -0
- package/src/command-palette/parts.tsx +20 -14
- package/src/command-palette/virtual.tsx +21 -0
- package/src/dialog/parts.tsx +10 -13
- package/src/dialog-layer.ts +5 -3
- package/src/error-boundary.ts +7 -20
- package/src/file-icon.ts +8 -9
- package/src/file-tree/empty-lines.ts +24 -8
- package/src/file-tree/helpers.ts +34 -0
- package/src/file-tree/highlight.ts +19 -0
- package/src/file-tree/index.parts.ts +1 -0
- package/src/file-tree/parts.tsx +41 -5
- package/src/file-tree/use-empty-lines.ts +22 -5
- package/src/file-tree/use-file-tree-shift.tsx +10 -0
- package/src/file-tree/use-file-tree.ts +2 -0
- package/src/file-tree/use-slots.ts +77 -12
- package/src/format/datetime.ts +42 -17
- package/src/format/duration.ts +28 -10
- package/src/format/index.ts +3 -0
- package/src/format/locale.ts +33 -42
- package/src/format/number.ts +21 -27
- package/src/format/regions.ts +4 -7
- package/src/icon-picker.ts +23 -16
- package/src/index.ts +8 -0
- package/src/key-value-list/parts.tsx +1 -5
- package/src/lazy-landings.ts +2 -0
- package/src/lazy.tsx +23 -19
- package/src/locales.ts +2 -6
- package/src/match.ts +14 -0
- package/src/menu/parts.tsx +30 -55
- package/src/middle-truncate.ts +12 -3
- package/src/modality.ts +4 -0
- package/src/placement.ts +2 -1
- package/src/platform.ts +2 -0
- package/src/popover/parts.tsx +3 -5
- package/src/popup-layer.ts +6 -4
- package/src/prefetch.ts +18 -5
- package/src/project-switcher-row.tsx +2 -0
- package/src/segmented-keys.ts +6 -0
- package/src/select-shared.ts +2 -0
- package/src/selection.ts +5 -5
- package/src/settings/ConfirmDialog.tsx +3 -0
- package/src/settings/MasterDetail.tsx +3 -9
- package/src/settings/SettingsDialog.tsx +27 -9
- package/src/settings/SettingsRow.tsx +16 -10
- package/src/settings/SettingsSection.tsx +10 -5
- package/src/settings/index.ts +3 -0
- package/src/settings/target.ts +1 -0
- package/src/shortcut-hints.ts +10 -5
- package/src/shortcut.ts +26 -6
- package/src/tabs/overflow.ts +8 -6
- package/src/tabs/parts.tsx +50 -26
- package/src/testing/dedupe.ts +27 -7
- package/src/testing/fake-source.ts +1 -0
- package/src/tint.ts +3 -5
- package/src/toast-store.ts +20 -14
- package/src/tree/parts.tsx +8 -0
- package/src/tree/shift.ts +6 -0
- package/src/tree/use-tree-shift.tsx +23 -7
- package/src/useAsyncAction.ts +8 -0
- package/src/useColumnResize.ts +24 -15
- package/src/useContentSize.ts +5 -0
- package/src/useMeasuredWidth.ts +1 -0
- package/src/useNow.ts +6 -11
- package/src/workspace/DropPreview.tsx +3 -5
- package/src/workspace/SplitView.tsx +109 -0
- package/src/workspace/TabStrip.tsx +42 -0
- package/src/workspace/Workspace.tsx +5 -0
- package/src/workspace/context.ts +1 -0
- package/src/workspace/geometry.ts +49 -0
- package/src/workspace/model.ts +72 -0
- package/src/workspace/preview.ts +15 -15
|
@@ -4,22 +4,17 @@ import clsx from 'clsx';
|
|
|
4
4
|
const LIST_WIDTHS = { 280: 'w-70', 320: 'w-80', 340: 'w-85' } as const;
|
|
5
5
|
|
|
6
6
|
export interface MasterDetailProps {
|
|
7
|
-
/*
|
|
7
|
+
/* Usually `MasterItem`s under their own headers. */
|
|
8
8
|
list: ReactNode;
|
|
9
|
+
|
|
9
10
|
listWidth: keyof typeof LIST_WIDTHS;
|
|
10
|
-
/* Names the list for a screen reader. */
|
|
11
11
|
listLabel: string;
|
|
12
12
|
detail: ReactNode;
|
|
13
13
|
className?: string;
|
|
14
14
|
ref?: Ref<HTMLDivElement>;
|
|
15
15
|
}
|
|
16
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
|
-
*/
|
|
17
|
+
/* Its section must be `split`, so the dialog hands the pane its whole height instead of a padded scrolling column. */
|
|
23
18
|
export function MasterDetail({ list, listWidth, listLabel, detail, className, ref }: MasterDetailProps) {
|
|
24
19
|
return (
|
|
25
20
|
<div ref={ref} className={clsx('flex min-h-0 min-w-0 grow border-t border-border max-[640px]:flex-col', className)}>
|
|
@@ -47,7 +42,6 @@ export interface MasterItemProps {
|
|
|
47
42
|
ref?: Ref<HTMLButtonElement>;
|
|
48
43
|
}
|
|
49
44
|
|
|
50
|
-
/* One row of the list. The picked one is lifted a shade. */
|
|
51
45
|
export function MasterItem({ selected, onSelect, children, className, ref }: MasterItemProps) {
|
|
52
46
|
return (
|
|
53
47
|
<button
|
|
@@ -17,34 +17,40 @@ export interface SettingsSectionEntry {
|
|
|
17
17
|
id: string;
|
|
18
18
|
icon: LucideIcon;
|
|
19
19
|
label: string;
|
|
20
|
-
/* One line under the pane title that says what the pane is about. */
|
|
21
20
|
description: string;
|
|
21
|
+
|
|
22
22
|
/* Rendered in a `LazyBoundary`, so a lazy component loads when its section is opened. */
|
|
23
23
|
pane: ComponentType;
|
|
24
|
+
|
|
24
25
|
/* A list beside a detail that scrolls on its own (`MasterDetail`), instead of one padded column. */
|
|
25
26
|
split?: boolean;
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
|
|
28
|
+
/* Runs to the pane's edges, with the title bar floating over its top; a `split` section draws none. */
|
|
28
29
|
hero?: ComponentType;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export interface SettingsGroupEntry {
|
|
32
33
|
/* The first group usually goes without one. */
|
|
33
34
|
label: string | null;
|
|
35
|
+
|
|
34
36
|
sections: readonly SettingsSectionEntry[];
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export interface SettingsSearchResult {
|
|
38
40
|
section: string;
|
|
41
|
+
|
|
39
42
|
/* The row to reveal (a `SettingsRow` `searchId`), or null for a result that is the pane itself. */
|
|
40
43
|
id: string | null;
|
|
44
|
+
|
|
41
45
|
label: string;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export interface SettingsSearch {
|
|
45
49
|
find(query: string): readonly SettingsSearchResult[];
|
|
46
|
-
|
|
50
|
+
|
|
51
|
+
/* The printed shortcut that focuses the field. */
|
|
47
52
|
hint?: string;
|
|
53
|
+
|
|
48
54
|
/* Focuses and selects the field each time it grows. */
|
|
49
55
|
focusAt?: number;
|
|
50
56
|
}
|
|
@@ -53,20 +59,24 @@ export interface SettingsDialogProps {
|
|
|
53
59
|
open: boolean;
|
|
54
60
|
onOpenChange(open: boolean): void;
|
|
55
61
|
section: string;
|
|
56
|
-
/* A tab or a section picked, or a search result with the row it leads to. */
|
|
57
62
|
onNavigate(next: { section: string; target?: string | null }): void;
|
|
58
63
|
groups: readonly SettingsGroupEntry[];
|
|
59
|
-
/* At the foot of the navigation, under the groups. */
|
|
60
64
|
footer?: readonly SettingsSectionEntry[];
|
|
65
|
+
|
|
61
66
|
/* Under the footer sections, past a hairline: a section whose tab the app draws itself (a `Tabs.Tab`
|
|
62
67
|
with the section's id as its value), such as an account. */
|
|
63
68
|
account?: { section: SettingsSectionEntry; tab: ReactNode };
|
|
69
|
+
|
|
64
70
|
search?: SettingsSearch;
|
|
71
|
+
|
|
65
72
|
/* The row a search result jumped to, lit until `onTargetShown`. */
|
|
66
73
|
target?: string | null;
|
|
74
|
+
|
|
67
75
|
onTargetShown?(): void;
|
|
68
|
-
|
|
76
|
+
|
|
77
|
+
/* On the popup. */
|
|
69
78
|
className?: string;
|
|
79
|
+
|
|
70
80
|
ref?: Ref<HTMLDivElement>;
|
|
71
81
|
}
|
|
72
82
|
|
|
@@ -87,6 +97,7 @@ function NavTab({ section }: { section: SettingsSectionEntry }) {
|
|
|
87
97
|
|
|
88
98
|
function SettingsNav({ groups, footer, account }: Pick<SettingsDialogProps, 'groups' | 'footer' | 'account'>) {
|
|
89
99
|
const { t } = useTranslation('ui');
|
|
100
|
+
|
|
90
101
|
return (
|
|
91
102
|
<Tabs.List aria-label={t('settings.sections')} activateOnFocus className="flex min-h-0 grow flex-col">
|
|
92
103
|
<div className="flex min-h-0 flex-col gap-3.5 overflow-y-auto">
|
|
@@ -148,8 +159,10 @@ function SearchField({ search, query, onQuery, onPick }: SearchFieldProps) {
|
|
|
148
159
|
event.stopPropagation();
|
|
149
160
|
onQuery('');
|
|
150
161
|
}
|
|
162
|
+
|
|
151
163
|
if (event.key === 'Enter') {
|
|
152
164
|
const first = search.find(query)[0];
|
|
165
|
+
|
|
153
166
|
if (first) {
|
|
154
167
|
onPick(first);
|
|
155
168
|
}
|
|
@@ -183,7 +196,6 @@ interface SearchResultsProps {
|
|
|
183
196
|
onPick(result: SettingsSearchResult): void;
|
|
184
197
|
}
|
|
185
198
|
|
|
186
|
-
/* In place of the navigation while a query is typed: the panes and rows that match, each a jump to where it lives. */
|
|
187
199
|
function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
|
|
188
200
|
const { t, i18n } = useTranslation('ui');
|
|
189
201
|
// The language is a dependency the linter cannot see; every result reads its words off i18next.
|
|
@@ -193,6 +205,7 @@ function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
|
|
|
193
205
|
if (results.length === 0) {
|
|
194
206
|
return <p className="px-2.5 py-1 text-xs break-words text-text-muted">{t('settings.search.none', { query: query.trim() })}</p>;
|
|
195
207
|
}
|
|
208
|
+
|
|
196
209
|
return (
|
|
197
210
|
<ul className="flex min-h-0 flex-col gap-0.5 overflow-y-auto" aria-label={t('settings.search.results')}>
|
|
198
211
|
{results.map((result) => (
|
|
@@ -211,7 +224,7 @@ function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
|
|
|
211
224
|
);
|
|
212
225
|
}
|
|
213
226
|
|
|
214
|
-
/*
|
|
227
|
+
/* A pane mounts only while its section is open. */
|
|
215
228
|
export function SettingsDialog({
|
|
216
229
|
open,
|
|
217
230
|
onOpenChange,
|
|
@@ -236,12 +249,15 @@ export function SettingsDialog({
|
|
|
236
249
|
const barFill = useRef<HTMLDivElement>(null);
|
|
237
250
|
// The latest callback behind a stable one, so a lit row's timer does not start over on every render.
|
|
238
251
|
const latestShown = useRef(onTargetShown);
|
|
252
|
+
|
|
239
253
|
useEffect(() => {
|
|
240
254
|
latestShown.current = onTargetShown;
|
|
241
255
|
});
|
|
256
|
+
|
|
242
257
|
const targetValue = { target, shown: () => latestShown.current?.() };
|
|
243
258
|
|
|
244
259
|
const labelOf = (id: string): string => sections.find((entry) => entry.id === id)?.label ?? id;
|
|
260
|
+
|
|
245
261
|
const pick = (result: SettingsSearchResult): void => onNavigate({ section: result.section, target: result.id });
|
|
246
262
|
|
|
247
263
|
return (
|
|
@@ -249,6 +265,7 @@ export function SettingsDialog({
|
|
|
249
265
|
open={open}
|
|
250
266
|
onOpenChange={(next) => {
|
|
251
267
|
onOpenChange(next);
|
|
268
|
+
|
|
252
269
|
if (!next) {
|
|
253
270
|
setQuery('');
|
|
254
271
|
}
|
|
@@ -302,6 +319,7 @@ export function SettingsDialog({
|
|
|
302
319
|
{sections.map((entry) => {
|
|
303
320
|
const Pane = entry.pane;
|
|
304
321
|
const Hero = entry.split ? undefined : entry.hero;
|
|
322
|
+
|
|
305
323
|
return (
|
|
306
324
|
<Tabs.Panel
|
|
307
325
|
key={entry.id}
|
|
@@ -11,18 +11,25 @@ const TARGET_MS = 1600;
|
|
|
11
11
|
export interface SettingsRowProps {
|
|
12
12
|
label: ReactNode;
|
|
13
13
|
description?: ReactNode;
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
/* It carries its own accessible name. */
|
|
15
16
|
control?: ReactNode;
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
/* Under the label line, at the whole width. */
|
|
17
19
|
children?: ReactNode;
|
|
18
|
-
|
|
20
|
+
|
|
21
|
+
/* For a row that only shows state. */
|
|
19
22
|
muted?: boolean;
|
|
20
|
-
|
|
23
|
+
|
|
24
|
+
/* Held to the label's first line (`TopIcon`) or a tile of its own. */
|
|
21
25
|
leading?: ReactNode;
|
|
22
|
-
|
|
26
|
+
|
|
27
|
+
/* For a row that only makes sense under the one above it. */
|
|
23
28
|
indent?: boolean;
|
|
29
|
+
|
|
24
30
|
/* The id a search result names to lead here. */
|
|
25
31
|
searchId?: string;
|
|
32
|
+
|
|
26
33
|
className?: string;
|
|
27
34
|
ref?: Ref<HTMLDivElement>;
|
|
28
35
|
}
|
|
@@ -34,7 +41,7 @@ export interface TopIconProps {
|
|
|
34
41
|
ref?: Ref<HTMLSpanElement>;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
/*
|
|
44
|
+
/* The box is one line of `text-sm` high, so the icon sits on the first line of text that may wrap. */
|
|
38
45
|
export function TopIcon({ icon, size = 16, className, ref }: TopIconProps) {
|
|
39
46
|
return (
|
|
40
47
|
<span ref={ref} className="flex h-5.5 shrink-0 items-center">
|
|
@@ -43,10 +50,7 @@ export function TopIcon({ icon, size = 16, className, ref }: TopIconProps) {
|
|
|
43
50
|
);
|
|
44
51
|
}
|
|
45
52
|
|
|
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
|
-
*/
|
|
53
|
+
/* Where label and control do not fit side by side, the control wraps under the label instead of widening the dialog. */
|
|
50
54
|
export function SettingsRow({ label, description, control, children, muted = false, leading, indent = false, searchId, className, ref }: SettingsRowProps) {
|
|
51
55
|
const row = useRef<HTMLDivElement>(null);
|
|
52
56
|
const { target, shown } = useSettingsTarget();
|
|
@@ -56,8 +60,10 @@ export function SettingsRow({ label, description, control, children, muted = fal
|
|
|
56
60
|
if (!targeted) {
|
|
57
61
|
return;
|
|
58
62
|
}
|
|
63
|
+
|
|
59
64
|
row.current?.scrollIntoView({ block: 'center' });
|
|
60
65
|
const timer = window.setTimeout(shown, TARGET_MS);
|
|
66
|
+
|
|
61
67
|
return () => window.clearTimeout(timer);
|
|
62
68
|
}, [targeted, shown]);
|
|
63
69
|
|
|
@@ -6,22 +6,27 @@ import { Icon } from '../Icon.tsx';
|
|
|
6
6
|
export interface SettingsSectionProps {
|
|
7
7
|
title?: string;
|
|
8
8
|
description?: ReactNode;
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
/* For a pane whose sections are kinds of thing. */
|
|
10
11
|
icon?: LucideIcon;
|
|
11
|
-
|
|
12
|
+
|
|
13
|
+
/* On the header line, after the action. */
|
|
12
14
|
tag?: ReactNode;
|
|
13
|
-
|
|
15
|
+
|
|
14
16
|
action?: ReactNode;
|
|
15
|
-
|
|
17
|
+
|
|
18
|
+
/* A note under the card. */
|
|
16
19
|
footer?: ReactNode;
|
|
20
|
+
|
|
17
21
|
children: ReactNode;
|
|
18
22
|
className?: string;
|
|
19
23
|
ref?: Ref<HTMLElement>;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
/*
|
|
26
|
+
/* Rows divide themselves with a hairline. Without a title it is the card alone. */
|
|
23
27
|
export function SettingsSection({ title, description, icon, tag, action, footer, children, className, ref }: SettingsSectionProps) {
|
|
24
28
|
const hasHeader = title !== undefined || description !== undefined || tag !== undefined || action !== undefined;
|
|
29
|
+
|
|
25
30
|
return (
|
|
26
31
|
<section ref={ref} className={clsx('flex min-w-0 flex-col gap-2.5', className)} aria-label={title}>
|
|
27
32
|
{hasHeader && (
|
package/src/settings/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/* The settings dialog and the parts a pane is built from. */
|
|
2
2
|
|
|
3
3
|
export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog.tsx';
|
|
4
|
+
|
|
4
5
|
export { DetailHeader, type DetailHeaderProps } from './DetailHeader.tsx';
|
|
5
6
|
export { MasterDetail, MasterItem, type MasterDetailProps, type MasterItemProps } from './MasterDetail.tsx';
|
|
7
|
+
|
|
6
8
|
export {
|
|
7
9
|
SettingsDialog,
|
|
8
10
|
type SettingsDialogProps,
|
|
@@ -11,6 +13,7 @@ export {
|
|
|
11
13
|
type SettingsSearchResult,
|
|
12
14
|
type SettingsSectionEntry
|
|
13
15
|
} from './SettingsDialog.tsx';
|
|
16
|
+
|
|
14
17
|
export { SettingsRow, TopIcon, type SettingsRowProps, type TopIconProps } from './SettingsRow.tsx';
|
|
15
18
|
export { SettingsSection, type SettingsSectionProps } from './SettingsSection.tsx';
|
|
16
19
|
export { useSettingsTarget } from './target.ts';
|
package/src/settings/target.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createContext, useContext } from 'react';
|
|
|
3
3
|
interface SettingsTarget {
|
|
4
4
|
/* The row a search result jumped to, by its `searchId`. */
|
|
5
5
|
target: string | null;
|
|
6
|
+
|
|
6
7
|
/* Called once the row was lit long enough, so it is not lit again on the next render. */
|
|
7
8
|
shown(): void;
|
|
8
9
|
}
|
package/src/shortcut-hints.ts
CHANGED
|
@@ -15,6 +15,7 @@ let nextId = 0;
|
|
|
15
15
|
export const registerShortcutHint = (element: Element, shortcut: Shortcut): (() => void) => {
|
|
16
16
|
nextId += 1;
|
|
17
17
|
targets.set(element, { id: nextId, shortcut });
|
|
18
|
+
|
|
18
19
|
return () => {
|
|
19
20
|
targets.delete(element);
|
|
20
21
|
};
|
|
@@ -24,11 +25,7 @@ export const shortcutHintTargets = (): ReadonlyMap<Element, HintTarget> => targe
|
|
|
24
25
|
|
|
25
26
|
export type HoldKey = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'repeat'>;
|
|
26
27
|
|
|
27
|
-
/*
|
|
28
|
-
* Whether the hints are up: the platform's modifier (Cmd on macOS, Ctrl elsewhere) held on its own for
|
|
29
|
-
* `HINT_DELAY_MS`. Any other key, the modifier coming up or the caller cancelling takes them down, so
|
|
30
|
-
* a shortcut pressed in passing never flashes them.
|
|
31
|
-
*/
|
|
28
|
+
/* The platform's modifier held on its own for `HINT_DELAY_MS`; any other key takes the hints down, so a shortcut pressed in passing never flashes them. */
|
|
32
29
|
export class ModifierHold {
|
|
33
30
|
private readonly apple: boolean;
|
|
34
31
|
private readonly onChange: (shown: boolean) => void;
|
|
@@ -45,7 +42,9 @@ export class ModifierHold {
|
|
|
45
42
|
if (event.repeat && event.key === this.modifierKey()) {
|
|
46
43
|
return;
|
|
47
44
|
}
|
|
45
|
+
|
|
48
46
|
this.cancel();
|
|
47
|
+
|
|
49
48
|
if (event.key === this.modifierKey() && !this.otherModifierHeld(event)) {
|
|
50
49
|
this.timer = setTimeout(() => {
|
|
51
50
|
this.timer = null;
|
|
@@ -66,6 +65,7 @@ export class ModifierHold {
|
|
|
66
65
|
clearTimeout(this.timer);
|
|
67
66
|
this.timer = null;
|
|
68
67
|
}
|
|
68
|
+
|
|
69
69
|
if (this.shown) {
|
|
70
70
|
this.shown = false;
|
|
71
71
|
this.onChange(false);
|
|
@@ -97,8 +97,10 @@ export interface HintPlacement {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const HINT_GAP_PX = 2;
|
|
100
|
+
|
|
100
101
|
/* Room a hint needs to be centered under its button; nearer a window edge it lines up with that edge instead. */
|
|
101
102
|
const EDGE_ROOM_PX = 48;
|
|
103
|
+
|
|
102
104
|
const HINT_HEIGHT_PX = 24;
|
|
103
105
|
|
|
104
106
|
/* Under the button, centered, and above it where the window ends first. */
|
|
@@ -107,11 +109,14 @@ export const placeHint = (rect: HintRect, viewport: { width: number; height: num
|
|
|
107
109
|
const top = Math.round(below ? rect.bottom + HINT_GAP_PX : rect.top - HINT_GAP_PX);
|
|
108
110
|
const translateY = below ? '0' : '-100%';
|
|
109
111
|
const center = (rect.left + rect.right) / 2;
|
|
112
|
+
|
|
110
113
|
if (center < EDGE_ROOM_PX) {
|
|
111
114
|
return { left: Math.round(rect.left), top, translateX: '0', translateY };
|
|
112
115
|
}
|
|
116
|
+
|
|
113
117
|
if (center > viewport.width - EDGE_ROOM_PX) {
|
|
114
118
|
return { right: Math.round(viewport.width - rect.right), top, translateX: '0', translateY };
|
|
115
119
|
}
|
|
120
|
+
|
|
116
121
|
return { left: Math.round(center), top, translateX: '-50%', translateY };
|
|
117
122
|
};
|
package/src/shortcut.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* physical key and exist for the few shortcuts that differ per platform on purpose.
|
|
2
|
+
* `mod` is Cmd on macOS and Ctrl everywhere else; `ctrl` and `meta` name the physical key, for the few
|
|
3
|
+
* shortcuts that differ per platform on purpose.
|
|
5
4
|
*/
|
|
6
5
|
export interface Shortcut {
|
|
7
6
|
mod: boolean;
|
|
@@ -9,12 +8,12 @@ export interface Shortcut {
|
|
|
9
8
|
meta: boolean;
|
|
10
9
|
alt: boolean;
|
|
11
10
|
shift: boolean;
|
|
11
|
+
|
|
12
12
|
/* An uppercase letter, a digit, a punctuation mark or a named key (`Enter`, `ArrowLeft`, `F2`).
|
|
13
13
|
Empty for modifiers held during a pointer gesture (`Mod` then "drag"), which never match a key. */
|
|
14
14
|
key: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/* What a shortcut is matched against. A real `KeyboardEvent` satisfies it, a test writes one by hand. */
|
|
18
17
|
export type KeyLike = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'key' | 'code'>;
|
|
19
18
|
|
|
20
19
|
/* Matched on the physical key: Shift turns `[` into `{` and Option turns most keys into a dead key. */
|
|
@@ -56,19 +55,25 @@ export const shortcut = (text: string): Shortcut => {
|
|
|
56
55
|
// A trailing `++` is the plus key itself, which a plain split would read as two empty words.
|
|
57
56
|
const words = text === '+' ? ['+'] : text.endsWith('++') ? [...text.slice(0, -2).split('+'), '+'] : text.split('+');
|
|
58
57
|
const result: Shortcut = { mod: false, ctrl: false, meta: false, alt: false, shift: false, key: '' };
|
|
58
|
+
|
|
59
59
|
for (const word of words) {
|
|
60
60
|
if (Object.hasOwn(MODIFIER_FIELDS, word)) {
|
|
61
61
|
result[MODIFIER_FIELDS[word]!] = true;
|
|
62
|
+
|
|
62
63
|
continue;
|
|
63
64
|
}
|
|
65
|
+
|
|
64
66
|
if (!isKey(word)) {
|
|
65
67
|
throw new Error(`Unknown key "${word}" in shortcut "${text}"`);
|
|
66
68
|
}
|
|
69
|
+
|
|
67
70
|
if (result.key !== '') {
|
|
68
71
|
throw new Error(`Shortcut "${text}" names two keys`);
|
|
69
72
|
}
|
|
73
|
+
|
|
70
74
|
result.key = word.length === 1 ? word.toUpperCase() : word;
|
|
71
75
|
}
|
|
76
|
+
|
|
72
77
|
return result;
|
|
73
78
|
};
|
|
74
79
|
|
|
@@ -77,16 +82,21 @@ const matchesKey = (key: string, event: KeyLike): boolean => {
|
|
|
77
82
|
if (/^[A-Z]$/.test(key)) {
|
|
78
83
|
return event.code === `Key${key}`;
|
|
79
84
|
}
|
|
85
|
+
|
|
80
86
|
if (/^[0-9]$/.test(key)) {
|
|
81
87
|
return event.code === `Digit${key}`;
|
|
82
88
|
}
|
|
89
|
+
|
|
83
90
|
const code = PUNCTUATION_CODES[key];
|
|
91
|
+
|
|
84
92
|
if (code) {
|
|
85
93
|
return event.code === code;
|
|
86
94
|
}
|
|
95
|
+
|
|
87
96
|
if (key === 'Space') {
|
|
88
97
|
return event.code === 'Space';
|
|
89
98
|
}
|
|
99
|
+
|
|
90
100
|
return event.key === key;
|
|
91
101
|
};
|
|
92
102
|
|
|
@@ -94,9 +104,11 @@ const matchesKey = (key: string, event: KeyLike): boolean => {
|
|
|
94
104
|
export const matchesShortcut = (target: Shortcut, event: KeyLike, apple: boolean): boolean => {
|
|
95
105
|
const meta = target.meta || (target.mod && apple);
|
|
96
106
|
const ctrl = target.ctrl || (target.mod && !apple);
|
|
107
|
+
|
|
97
108
|
if (target.key === '' || event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== target.alt || event.shiftKey !== target.shift) {
|
|
98
109
|
return false;
|
|
99
110
|
}
|
|
111
|
+
|
|
100
112
|
return matchesKey(target.key, event);
|
|
101
113
|
};
|
|
102
114
|
|
|
@@ -113,19 +125,23 @@ const KEY_LABELS: Record<string, { apple: string; other: string }> = {
|
|
|
113
125
|
ArrowDown: { apple: '↓', other: '↓' }
|
|
114
126
|
};
|
|
115
127
|
|
|
116
|
-
/*
|
|
128
|
+
/* In the order the platform prints them. */
|
|
117
129
|
export const shortcutParts = (target: Shortcut, apple: boolean): string[] => {
|
|
118
130
|
const parts: string[] = [];
|
|
131
|
+
|
|
119
132
|
if (apple) {
|
|
120
133
|
if (target.ctrl) {
|
|
121
134
|
parts.push('⌃');
|
|
122
135
|
}
|
|
136
|
+
|
|
123
137
|
if (target.alt) {
|
|
124
138
|
parts.push('⌥');
|
|
125
139
|
}
|
|
140
|
+
|
|
126
141
|
if (target.shift) {
|
|
127
142
|
parts.push('⇧');
|
|
128
143
|
}
|
|
144
|
+
|
|
129
145
|
if (target.meta || target.mod) {
|
|
130
146
|
parts.push('⌘');
|
|
131
147
|
}
|
|
@@ -133,20 +149,25 @@ export const shortcutParts = (target: Shortcut, apple: boolean): string[] => {
|
|
|
133
149
|
if (target.ctrl || target.mod) {
|
|
134
150
|
parts.push('Ctrl');
|
|
135
151
|
}
|
|
152
|
+
|
|
136
153
|
if (target.alt) {
|
|
137
154
|
parts.push('Alt');
|
|
138
155
|
}
|
|
156
|
+
|
|
139
157
|
if (target.shift) {
|
|
140
158
|
parts.push('Shift');
|
|
141
159
|
}
|
|
160
|
+
|
|
142
161
|
if (target.meta) {
|
|
143
162
|
parts.push('Meta');
|
|
144
163
|
}
|
|
145
164
|
}
|
|
165
|
+
|
|
146
166
|
if (target.key !== '') {
|
|
147
167
|
const label = KEY_LABELS[target.key];
|
|
148
168
|
parts.push(label ? label[apple ? 'apple' : 'other'] : target.key);
|
|
149
169
|
}
|
|
170
|
+
|
|
150
171
|
return parts;
|
|
151
172
|
};
|
|
152
173
|
|
|
@@ -171,5 +192,4 @@ export const KEY_SHORTCUTS = {
|
|
|
171
192
|
rename: /* @__PURE__ */ shortcut('F2')
|
|
172
193
|
} as const;
|
|
173
194
|
|
|
174
|
-
/* Whether the platform's own modifier is down during a pointer gesture: Cmd on macOS, Ctrl elsewhere. */
|
|
175
195
|
export const isModHeld = (event: Pick<KeyLike, 'metaKey' | 'ctrlKey'>, apple: boolean): boolean => (apple ? event.metaKey : event.ctrlKey);
|
package/src/tabs/overflow.ts
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The tabs a strip shows, by index, given the width of every tab, the room the strip has, the gap
|
|
3
|
-
* between two tabs and the width of the button that opens the rest. Everything fits, or the tabs fit
|
|
4
|
-
* from the start up to the button. The kept tabs (the picked one, the pinned ones) are never left to
|
|
5
|
-
* the menu: they take the place of the last ones that no longer fit beside them.
|
|
6
|
-
*/
|
|
1
|
+
/* The kept tabs (the picked one, the pinned ones) never go to the menu: they take the place of the last ones that no longer fit beside them. */
|
|
7
2
|
export const shownTabs = (widths: readonly number[], room: number, gap: number, more: number, kept: readonly number[]): number[] => {
|
|
8
3
|
const span = (indices: readonly number[]): number => indices.reduce((sum, index) => sum + widths[index]!, 0) + gap * Math.max(0, indices.length - 1);
|
|
4
|
+
|
|
9
5
|
const all = widths.map((_, index) => index);
|
|
6
|
+
|
|
10
7
|
// Half a pixel of slack, since a measured width is fractional and the room is not.
|
|
11
8
|
if (span(all) <= room + 0.5) {
|
|
12
9
|
return all;
|
|
13
10
|
}
|
|
11
|
+
|
|
14
12
|
const limit = room - more - gap + 0.5;
|
|
15
13
|
const held = all.filter((index) => kept.includes(index));
|
|
16
14
|
const shown: number[] = [];
|
|
15
|
+
|
|
17
16
|
for (const index of all) {
|
|
18
17
|
if (held.includes(index)) {
|
|
19
18
|
continue;
|
|
20
19
|
}
|
|
20
|
+
|
|
21
21
|
if (span([...held, ...shown, index]) > limit) {
|
|
22
22
|
break;
|
|
23
23
|
}
|
|
24
|
+
|
|
24
25
|
shown.push(index);
|
|
25
26
|
}
|
|
27
|
+
|
|
26
28
|
return [...held, ...shown].sort((left, right) => left - right);
|
|
27
29
|
};
|