@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
package/src/Toasts.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useState, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { CircleAlert, CircleCheck, Trash, X } from 'lucide-react';
|
|
5
|
+
import { Icon } from './Icon.tsx';
|
|
6
|
+
import { IconButton } from './IconButton.tsx';
|
|
7
|
+
import { Kbd } from './Kbd.tsx';
|
|
8
|
+
import { Spinner } from './Spinner.tsx';
|
|
9
|
+
import { Surface } from './Surface.tsx';
|
|
10
|
+
import { elapsedOf, type Toast, type ToastAction, type ToastDeadline, type ToastStoreHook } from './toast-store.ts';
|
|
11
|
+
|
|
12
|
+
const ICON = {
|
|
13
|
+
success: CircleCheck,
|
|
14
|
+
error: CircleAlert,
|
|
15
|
+
deleted: Trash
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const TONE = {
|
|
19
|
+
progress: 'text-text-muted',
|
|
20
|
+
success: 'text-status-idle',
|
|
21
|
+
error: 'text-status-error',
|
|
22
|
+
deleted: 'text-text-muted'
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/* The box is as tall as the title's own line, so what sits beside the title centers on that line. */
|
|
26
|
+
const TITLE_LINE = 'flex h-(--text-sm--line-height) shrink-0 items-center';
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* How long the offer in a toast still stands, drawn from the timer that takes the toast away: the
|
|
30
|
+
* ring starts as far along as that timer already is, so both end together. With motion off it stays full.
|
|
31
|
+
*/
|
|
32
|
+
function TimerRing({ deadline, onDismiss }: { deadline: ToastDeadline; onDismiss(): void }) {
|
|
33
|
+
const { t } = useTranslation('ui');
|
|
34
|
+
const lifetime = deadline.end - deadline.start;
|
|
35
|
+
const [elapsed] = useState(() => elapsedOf(deadline, Date.now()));
|
|
36
|
+
return (
|
|
37
|
+
<IconButton size="xs" label={t('action.dismiss')} className="group/ring relative -my-px" onClick={onDismiss}>
|
|
38
|
+
<svg width={16} height={16} viewBox="0 0 16 16" className="-rotate-90" aria-hidden>
|
|
39
|
+
<circle cx={8} cy={8} r={6} fill="none" strokeWidth={2} className="stroke-border" />
|
|
40
|
+
<circle
|
|
41
|
+
cx={8}
|
|
42
|
+
cy={8}
|
|
43
|
+
r={6}
|
|
44
|
+
fill="none"
|
|
45
|
+
strokeWidth={2}
|
|
46
|
+
pathLength={1}
|
|
47
|
+
strokeDasharray={1}
|
|
48
|
+
className="toast-timer-arc stroke-current"
|
|
49
|
+
style={{ animationDuration: `${lifetime}ms`, animationDelay: `${-elapsed}ms` }}
|
|
50
|
+
/>
|
|
51
|
+
</svg>
|
|
52
|
+
<Icon icon={X} size={8} className="absolute opacity-0 group-hover/ring:opacity-100 group-focus-visible/ring:opacity-100" />
|
|
53
|
+
</IconButton>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ActionButton({ action }: { action: ToastAction }) {
|
|
58
|
+
return (
|
|
59
|
+
<span className="flex items-center gap-2">
|
|
60
|
+
<button className="rounded-sm text-sm font-semibold text-text hover:text-text-muted" onClick={action.run}>
|
|
61
|
+
{action.label}
|
|
62
|
+
</button>
|
|
63
|
+
{action.shortcut && <Kbd shortcut={action.shortcut} className="font-sans text-xs text-text-faint" />}
|
|
64
|
+
</span>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ToastCard<T extends Toast>({ toast, footer, onDismiss }: { toast: T; footer?: (toast: T) => ReactNode; onDismiss(): void }) {
|
|
69
|
+
const { t } = useTranslation('ui');
|
|
70
|
+
// A toast that goes by itself keeps its close button out of sight until the pointer or the keyboard is on it.
|
|
71
|
+
const leaves = toast.kind === 'deleted' || (toast.kind === 'success' && toast.persist !== true);
|
|
72
|
+
const actions = [...(toast.action === undefined ? [] : [toast.action]), ...(toast.actions ?? [])];
|
|
73
|
+
// An offer that runs out shows how long it has left instead.
|
|
74
|
+
const countdown = actions.length > 0 ? toast.deadline : undefined;
|
|
75
|
+
return (
|
|
76
|
+
<Surface className="group flex items-start gap-3 rounded-[10px] py-2.5 pr-2 pl-3">
|
|
77
|
+
<span className={clsx(TITLE_LINE, 'w-4 justify-center')}>
|
|
78
|
+
{toast.kind === 'progress' ? (
|
|
79
|
+
<Spinner size={16} className={TONE.progress} />
|
|
80
|
+
) : (
|
|
81
|
+
<Icon icon={ICON[toast.kind]} size={16} className={TONE[toast.kind]} />
|
|
82
|
+
)}
|
|
83
|
+
</span>
|
|
84
|
+
<div className="flex min-w-0 grow flex-col gap-1">
|
|
85
|
+
<span className="text-sm text-text">{toast.title}</span>
|
|
86
|
+
{/* Wraps rather than clips: the line under the title is the one that says what went
|
|
87
|
+
wrong, and a reason cut off at the card's edge is no reason at all. */}
|
|
88
|
+
{toast.description !== undefined && toast.description !== '' && (
|
|
89
|
+
<span className="text-xs break-words text-pretty text-text-muted">{toast.description}</span>
|
|
90
|
+
)}
|
|
91
|
+
{/* Two or more would squeeze the title beside them, so they get a line of their own. */}
|
|
92
|
+
{actions.length > 1 && (
|
|
93
|
+
<span className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
94
|
+
{actions.map((action) => (
|
|
95
|
+
<ActionButton key={action.label} action={action} />
|
|
96
|
+
))}
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
{footer?.(toast)}
|
|
100
|
+
</div>
|
|
101
|
+
{actions.length === 1 && (
|
|
102
|
+
<span className={TITLE_LINE}>
|
|
103
|
+
<ActionButton action={actions[0]} />
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
{countdown !== undefined ? (
|
|
107
|
+
<TimerRing key={countdown.start} deadline={countdown} onDismiss={onDismiss} />
|
|
108
|
+
) : (
|
|
109
|
+
<IconButton
|
|
110
|
+
icon={X}
|
|
111
|
+
size="xs"
|
|
112
|
+
label={t('action.dismiss')}
|
|
113
|
+
className={clsx('-my-px', leaves && 'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100')}
|
|
114
|
+
onClick={onDismiss}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</Surface>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ToastsProps<T extends Toast> {
|
|
122
|
+
store: ToastStoreHook<T>;
|
|
123
|
+
/* Drawn under the description, for what only the app knows how to offer, such as copying a command's output. */
|
|
124
|
+
footer?: (toast: T) => ReactNode;
|
|
125
|
+
className?: string;
|
|
126
|
+
ref?: Ref<HTMLDivElement>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/*
|
|
130
|
+
* Bottom right, over everything: what an action is doing while it runs and how it went when it is
|
|
131
|
+
* over, and a deletion that can still be taken back. A success takes itself away after four seconds,
|
|
132
|
+
* a deletion after eight, and a failure waits to be read.
|
|
133
|
+
*/
|
|
134
|
+
export function Toasts<T extends Toast>({ store, footer, className, ref }: ToastsProps<T>) {
|
|
135
|
+
const toasts = store((state) => state.toasts);
|
|
136
|
+
if (toasts.length === 0) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
ref={ref}
|
|
142
|
+
className={clsx('fixed right-4 bottom-4 z-(--z-popup) flex w-90 max-w-[calc(100vw-32px)] flex-col gap-2', className)}
|
|
143
|
+
role="status"
|
|
144
|
+
aria-live="polite"
|
|
145
|
+
>
|
|
146
|
+
{toasts.map((toast) => (
|
|
147
|
+
<ToastCard key={toast.id} toast={toast} footer={footer} onDismiss={() => store.getState().dismiss(toast.id)} />
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
package/src/Tooltip.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
|
|
3
|
+
import { Kbd } from './Kbd.tsx';
|
|
4
|
+
import type { Shortcut } from './shortcut.ts';
|
|
5
|
+
import { registerShortcutHint } from './shortcut-hints.ts';
|
|
6
|
+
|
|
7
|
+
export type TooltipSide = 'top' | 'bottom' | 'left' | 'right';
|
|
8
|
+
|
|
9
|
+
/* One provider per app. Tooltips share a delay, so moving along a row of buttons feels instant. `UIProvider` mounts it. */
|
|
10
|
+
export function TooltipProvider({ children }: { children: ReactNode }) {
|
|
11
|
+
return (
|
|
12
|
+
<BaseTooltip.Provider delay={150} closeDelay={0}>
|
|
13
|
+
{children}
|
|
14
|
+
</BaseTooltip.Provider>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TooltipProps {
|
|
19
|
+
label: ReactNode;
|
|
20
|
+
/* A shortcut, or a short phrase about a key that is not one ("Shift skips the cache"). */
|
|
21
|
+
kbd?: Shortcut | string;
|
|
22
|
+
side?: TooltipSide;
|
|
23
|
+
sideOffset?: number;
|
|
24
|
+
/* Makes the label the accessible name of the trigger as well: what an icon-only button needs,
|
|
25
|
+
and the way to keep the name and the tooltip from ever saying two different things. */
|
|
26
|
+
name?: boolean;
|
|
27
|
+
/* Keeps the hint closed, for a trigger that only needs one some of the time. */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
/* The trigger element. Its own children and handlers are kept; Base UI merges the tooltip props in. */
|
|
30
|
+
children: ReactElement<Record<string, unknown>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* A hint on hover and on keyboard focus. Every hint in an app is one of these, never a `title`. */
|
|
34
|
+
export function Tooltip({ label, kbd, side = 'top', sideOffset = 6, name = false, disabled = false, children }: TooltipProps) {
|
|
35
|
+
// Held Cmd (Ctrl off macOS) prints this shortcut under the trigger (`ShortcutHints`).
|
|
36
|
+
const hintRef = useCallback(
|
|
37
|
+
(element: HTMLElement | null) => {
|
|
38
|
+
if (element === null || kbd === undefined || typeof kbd === 'string') {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return registerShortcutHint(element, kbd);
|
|
42
|
+
},
|
|
43
|
+
[kbd]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<BaseTooltip.Root disabled={disabled}>
|
|
48
|
+
<BaseTooltip.Trigger ref={hintRef} render={children} aria-label={name && typeof label === 'string' ? label : undefined} />
|
|
49
|
+
<BaseTooltip.Portal>
|
|
50
|
+
<BaseTooltip.Positioner side={side} sideOffset={sideOffset} className="tooltip-positioner">
|
|
51
|
+
<BaseTooltip.Popup className="tooltip-popup">
|
|
52
|
+
<BaseTooltip.Viewport className="max-w-72 px-[9px] py-[5px] text-pretty">
|
|
53
|
+
<span className="wrap-anywhere">{label}</span>
|
|
54
|
+
{/* The viewport wraps its children in a div of its own, so the 8px
|
|
55
|
+
between the label and the shortcut has to sit on the shortcut itself. */}
|
|
56
|
+
{kbd !== undefined &&
|
|
57
|
+
(typeof kbd === 'string' ? (
|
|
58
|
+
<Kbd variant="inline" className="ml-2">
|
|
59
|
+
{kbd}
|
|
60
|
+
</Kbd>
|
|
61
|
+
) : (
|
|
62
|
+
<Kbd shortcut={kbd} variant="inline" className="ml-2" />
|
|
63
|
+
))}
|
|
64
|
+
</BaseTooltip.Viewport>
|
|
65
|
+
</BaseTooltip.Popup>
|
|
66
|
+
</BaseTooltip.Positioner>
|
|
67
|
+
</BaseTooltip.Portal>
|
|
68
|
+
</BaseTooltip.Root>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useLayoutEffect, useState, type ReactNode } from 'react';
|
|
2
|
+
import type { i18n as I18n } from 'i18next';
|
|
3
|
+
import { I18nextProvider } from 'react-i18next';
|
|
4
|
+
import { setFormatSource, type FormatSource } from './format/locale.ts';
|
|
5
|
+
import { addUiResources } from './locales.ts';
|
|
6
|
+
import { startInputModality } from './modality.ts';
|
|
7
|
+
import { TooltipProvider } from './Tooltip.tsx';
|
|
8
|
+
|
|
9
|
+
export interface UIProviderProps {
|
|
10
|
+
/* The app's i18next instance. The library's `ui` namespace is added to it, in every language it ships. */
|
|
11
|
+
i18n: I18n;
|
|
12
|
+
/* Where the formatters read the language and the region a person set. Without one they write English in the region of that language. */
|
|
13
|
+
formatSource?: FormatSource;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Everything the library needs from an app, once, around the whole tree: its words in the app's
|
|
19
|
+
* i18next, the source of the formatters, the shared tooltip delay and the note of which input device
|
|
20
|
+
* is in use. Mount it above the first component of the library.
|
|
21
|
+
*/
|
|
22
|
+
export function UIProvider({ i18n, formatSource, children }: UIProviderProps) {
|
|
23
|
+
const [handed, setHanded] = useState<{ i18n: I18n | null; source: FormatSource | undefined }>({ i18n: null, source: undefined });
|
|
24
|
+
// In render and not in an effect, so the first child already reads the words and the region.
|
|
25
|
+
// Both calls only store what they are handed, so a render React throws away leaves nothing wrong behind.
|
|
26
|
+
if (handed.i18n !== i18n || handed.source !== formatSource) {
|
|
27
|
+
addUiResources(i18n);
|
|
28
|
+
if (formatSource !== undefined) {
|
|
29
|
+
setFormatSource(formatSource);
|
|
30
|
+
}
|
|
31
|
+
setHanded({ i18n, source: formatSource });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
useLayoutEffect(startInputModality, []);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<I18nextProvider i18n={i18n}>
|
|
38
|
+
<TooltipProvider>{children}</TooltipProvider>
|
|
39
|
+
</I18nextProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/Waveform.tsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { useMemo, useRef, type KeyboardEvent, type PointerEvent, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { formatClockDuration } from './format/duration.ts';
|
|
5
|
+
import { mergeRefs } from './merge-refs.ts';
|
|
6
|
+
import { useMeasuredWidth } from './useMeasuredWidth.ts';
|
|
7
|
+
|
|
8
|
+
/* About one bar per 3px, each 2px wide; a bar sits at its own time, so it lines up with the marks and the playhead. */
|
|
9
|
+
const BAR_PITCH = 3;
|
|
10
|
+
const BAR_WIDTH = 2;
|
|
11
|
+
|
|
12
|
+
/* The height of a silent bar, so silence still reads as a line. */
|
|
13
|
+
const SILENCE = 0.04;
|
|
14
|
+
|
|
15
|
+
/* Page Up and Page Down move this many steps, as a Base UI slider does. */
|
|
16
|
+
const PAGE_STEPS = 10;
|
|
17
|
+
|
|
18
|
+
/* The loudest sample under each bar, or null for a bar past either end of the audio. */
|
|
19
|
+
const barsOf = (levels: readonly number[], duration: number, from: number, to: number, count: number): (number | null)[] => {
|
|
20
|
+
if (count <= 0 || levels.length === 0 || duration <= 0 || to <= from) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const perSecond = levels.length / duration;
|
|
24
|
+
return Array.from({ length: count }, (_, i) => {
|
|
25
|
+
const start = from + ((to - from) * i) / count;
|
|
26
|
+
const end = from + ((to - from) * (i + 1)) / count;
|
|
27
|
+
if (end <= 0 || start >= duration) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const first = Math.max(0, Math.floor(start * perSecond));
|
|
31
|
+
const last = Math.min(levels.length - 1, Math.max(first, Math.ceil(end * perSecond) - 1));
|
|
32
|
+
let level = 0;
|
|
33
|
+
for (let index = first; index <= last; index++) {
|
|
34
|
+
level = Math.max(level, levels[index] ?? 0);
|
|
35
|
+
}
|
|
36
|
+
return Math.min(1, Math.max(SILENCE, level));
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface WaveformProps {
|
|
41
|
+
/* The loudness as samples spread evenly over the duration, from 0 for silence to 1 for the loudest, at any rate. */
|
|
42
|
+
levels: readonly number[];
|
|
43
|
+
/* In seconds. */
|
|
44
|
+
duration: number;
|
|
45
|
+
/* The stretch it shows, in seconds, such as the visible part of a zoomed timeline. Defaults to the whole. */
|
|
46
|
+
range?: readonly [from: number, to: number];
|
|
47
|
+
/* How far it has played, in seconds. Left out, the waveform draws no playhead. */
|
|
48
|
+
value?: number;
|
|
49
|
+
/* Seeks to a time in seconds. Without it the waveform only shows. */
|
|
50
|
+
onValueChange?(value: number): void;
|
|
51
|
+
/* The time a drag lets go at, and the one a key moved to: where a player resumes what it held during the drag. */
|
|
52
|
+
onValueCommitted?(value: number): void;
|
|
53
|
+
/* How far an arrow key moves the playhead, in seconds. */
|
|
54
|
+
step?: number;
|
|
55
|
+
/* Hears a key before the waveform does. A key it calls `preventDefault` on, the waveform leaves alone and lets
|
|
56
|
+
bubble, for a player around it that binds the key to something else. */
|
|
57
|
+
onKeyDown?(e: KeyboardEvent<HTMLDivElement>): void;
|
|
58
|
+
/* Times in seconds drawn as lines through the waveform, such as cuts. */
|
|
59
|
+
marks?: readonly number[];
|
|
60
|
+
label: string;
|
|
61
|
+
/* Its height, which the waveform fills. */
|
|
62
|
+
className?: string;
|
|
63
|
+
ref?: Ref<HTMLDivElement>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* The loudness of a piece of audio over time, played up to the playhead, where a press or a drag seeks. */
|
|
67
|
+
export function Waveform({
|
|
68
|
+
levels,
|
|
69
|
+
duration,
|
|
70
|
+
range,
|
|
71
|
+
value,
|
|
72
|
+
onValueChange,
|
|
73
|
+
onValueCommitted,
|
|
74
|
+
step = 1,
|
|
75
|
+
onKeyDown,
|
|
76
|
+
marks = [],
|
|
77
|
+
label,
|
|
78
|
+
className,
|
|
79
|
+
ref
|
|
80
|
+
}: WaveformProps) {
|
|
81
|
+
const { t } = useTranslation('ui');
|
|
82
|
+
const [measure, width] = useMeasuredWidth();
|
|
83
|
+
const rootRef = useMemo(() => mergeRefs(ref, measure), [ref, measure]);
|
|
84
|
+
const dragged = useRef(0);
|
|
85
|
+
const [from, to] = range ?? [0, duration];
|
|
86
|
+
const span = to - from;
|
|
87
|
+
const count = Math.floor(width / BAR_PITCH);
|
|
88
|
+
const bars = useMemo(() => barsOf(levels, duration, from, to, count), [levels, duration, from, to, count]);
|
|
89
|
+
|
|
90
|
+
const positionOf = (time: number): number => (span > 0 ? (time - from) / span : 0);
|
|
91
|
+
const inView = (time: number): boolean => positionOf(time) >= 0 && positionOf(time) <= 1;
|
|
92
|
+
const clamp = (time: number): number => Math.min(duration, Math.max(0, time));
|
|
93
|
+
|
|
94
|
+
const seekAt = (e: PointerEvent<HTMLDivElement>): void => {
|
|
95
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
96
|
+
if (rect.width > 0) {
|
|
97
|
+
dragged.current = clamp(from + ((e.clientX - rect.left) / rect.width) * span);
|
|
98
|
+
onValueChange?.(dragged.current);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const letGo = (e: PointerEvent<HTMLDivElement>): void => {
|
|
103
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
104
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
105
|
+
onValueCommitted?.(dragged.current);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const moveByKey = (e: KeyboardEvent<HTMLDivElement>): void => {
|
|
110
|
+
onKeyDown?.(e);
|
|
111
|
+
if (e.defaultPrevented) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const at = value ?? 0;
|
|
115
|
+
const moves: Record<string, number> = {
|
|
116
|
+
ArrowLeft: at - step,
|
|
117
|
+
ArrowDown: at - step,
|
|
118
|
+
ArrowRight: at + step,
|
|
119
|
+
ArrowUp: at + step,
|
|
120
|
+
PageDown: at - step * PAGE_STEPS,
|
|
121
|
+
PageUp: at + step * PAGE_STEPS,
|
|
122
|
+
Home: 0,
|
|
123
|
+
End: duration
|
|
124
|
+
};
|
|
125
|
+
const next = moves[e.key];
|
|
126
|
+
if (next === undefined) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// The slider owns these keys; a transport listening higher up would move the playhead a second time.
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
onValueChange?.(clamp(next));
|
|
133
|
+
onValueCommitted?.(clamp(next));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const slider =
|
|
137
|
+
onValueChange === undefined
|
|
138
|
+
? { role: 'img' }
|
|
139
|
+
: {
|
|
140
|
+
role: 'slider',
|
|
141
|
+
tabIndex: 0,
|
|
142
|
+
'aria-valuemin': 0,
|
|
143
|
+
'aria-valuemax': duration,
|
|
144
|
+
'aria-valuenow': value ?? 0,
|
|
145
|
+
'aria-valuetext': t('waveform.position', {
|
|
146
|
+
time: formatClockDuration((value ?? 0) * 1000),
|
|
147
|
+
duration: formatClockDuration(duration * 1000)
|
|
148
|
+
}),
|
|
149
|
+
onPointerDown: (e: PointerEvent<HTMLDivElement>) => {
|
|
150
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
151
|
+
seekAt(e);
|
|
152
|
+
},
|
|
153
|
+
onPointerMove: (e: PointerEvent<HTMLDivElement>) => {
|
|
154
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
155
|
+
seekAt(e);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
onPointerUp: letGo,
|
|
159
|
+
onPointerCancel: letGo,
|
|
160
|
+
onKeyDown: moveByKey
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
ref={rootRef}
|
|
166
|
+
aria-label={label}
|
|
167
|
+
{...slider}
|
|
168
|
+
className={clsx('relative select-none', onValueChange !== undefined && 'focus-ring cursor-ew-resize touch-none', className)}
|
|
169
|
+
>
|
|
170
|
+
<svg className="absolute inset-0 size-full" viewBox={`0 0 ${Math.max(1, width)} 100`} preserveAspectRatio="none" aria-hidden>
|
|
171
|
+
{bars.map((level, i) => {
|
|
172
|
+
if (level === null) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const center = from + (span * (i + 0.5)) / count;
|
|
176
|
+
return (
|
|
177
|
+
<rect
|
|
178
|
+
key={i}
|
|
179
|
+
x={Math.round((i * width) / count)}
|
|
180
|
+
y={(100 - level * 100) / 2}
|
|
181
|
+
width={BAR_WIDTH}
|
|
182
|
+
height={level * 100}
|
|
183
|
+
className={value !== undefined && center <= value ? 'fill-accent' : 'fill-text-faint'}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
{marks.filter(inView).map((mark) => {
|
|
188
|
+
const x = Math.round(positionOf(mark) * width) + 0.5;
|
|
189
|
+
return (
|
|
190
|
+
<line key={mark} x1={x} x2={x} y1={0} y2={100} strokeWidth={1} vectorEffect="non-scaling-stroke" className="stroke-accent opacity-60" />
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</svg>
|
|
194
|
+
{value !== undefined && inView(value) && (
|
|
195
|
+
<span className="pointer-events-none absolute inset-y-0 w-px bg-accent" style={{ left: `${positionOf(value) * 100}%` }} />
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
package/src/Wipe.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useRef, type KeyboardEvent, type PointerEvent, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { ChevronsLeftRight } from 'lucide-react';
|
|
4
|
+
import { Icon } from './Icon.tsx';
|
|
5
|
+
import { formatPercent } from './format/number.ts';
|
|
6
|
+
import { mergeRefs } from './merge-refs.ts';
|
|
7
|
+
import { splitAt, splitForKey } from './wipe-split.ts';
|
|
8
|
+
|
|
9
|
+
export interface WipeProps {
|
|
10
|
+
/* The share of the width left of the handle, from 0 to 1. */
|
|
11
|
+
value: number;
|
|
12
|
+
onValueChange(value: number): void;
|
|
13
|
+
before: ReactNode;
|
|
14
|
+
after: ReactNode;
|
|
15
|
+
label: string;
|
|
16
|
+
handle?: 'knob' | 'line';
|
|
17
|
+
className?: string;
|
|
18
|
+
ref?: Ref<HTMLDivElement>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Two versions of one picture in one frame: `before` left of the split and `after` right of it, with a
|
|
23
|
+
* handle between them a person drags or moves with the arrow keys. It fills the positioned element it
|
|
24
|
+
* sits in. The handle is a line with a round knob on it, or the plain line, which is dragged anywhere
|
|
25
|
+
* along its height.
|
|
26
|
+
*/
|
|
27
|
+
export function Wipe({ value: split, onValueChange: onSplit, before, after, label, handle = 'knob', className, ref }: WipeProps) {
|
|
28
|
+
const frame = useRef<HTMLDivElement>(null);
|
|
29
|
+
const percent = `${split * 100}%`;
|
|
30
|
+
|
|
31
|
+
function moveTo(clientX: number): void {
|
|
32
|
+
const rect = frame.current?.getBoundingClientRect();
|
|
33
|
+
const next = rect ? splitAt(clientX, rect) : null;
|
|
34
|
+
if (next !== null) {
|
|
35
|
+
onSplit(next);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function onPointerDown(e: PointerEvent<HTMLDivElement>): void {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
43
|
+
moveTo(e.clientX);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onPointerMove(e: PointerEvent<HTMLDivElement>): void {
|
|
47
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
48
|
+
moveTo(e.clientX);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onKeyDown(e: KeyboardEvent<HTMLDivElement>): void {
|
|
53
|
+
const next = splitForKey(split, e.key);
|
|
54
|
+
if (next === null) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Only while the handle has the focus, so a canvas or a timeline around it never hears these keys.
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
onSplit(next);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div ref={mergeRefs(frame, ref)} className={clsx('absolute inset-0', className)}>
|
|
65
|
+
<div className="absolute inset-0">{before}</div>
|
|
66
|
+
<div className="absolute inset-0" style={{ clipPath: `inset(0 0 0 ${percent})` }}>
|
|
67
|
+
{after}
|
|
68
|
+
</div>
|
|
69
|
+
<div
|
|
70
|
+
role="slider"
|
|
71
|
+
tabIndex={0}
|
|
72
|
+
aria-label={label}
|
|
73
|
+
aria-orientation="horizontal"
|
|
74
|
+
aria-valuemin={0}
|
|
75
|
+
aria-valuemax={100}
|
|
76
|
+
aria-valuenow={Math.round(split * 100)}
|
|
77
|
+
aria-valuetext={formatPercent(Math.round(split * 100))}
|
|
78
|
+
className={clsx('group absolute inset-y-0 z-10 -ml-3.5 w-7 cursor-ew-resize touch-none', handle === 'line' && 'outline-none')}
|
|
79
|
+
style={{ left: percent }}
|
|
80
|
+
onPointerDown={onPointerDown}
|
|
81
|
+
onPointerMove={onPointerMove}
|
|
82
|
+
onKeyDown={onKeyDown}
|
|
83
|
+
>
|
|
84
|
+
{/* Without a knob the line itself shows the focus, since a ring around its wide grip would read as two more lines. */}
|
|
85
|
+
<span
|
|
86
|
+
className={clsx(
|
|
87
|
+
'absolute inset-y-0 left-3.25 w-0.5 bg-media-handle shadow-media-handle-edge',
|
|
88
|
+
handle === 'line' && 'group-focus-visible:bg-accent'
|
|
89
|
+
)}
|
|
90
|
+
/>
|
|
91
|
+
{handle === 'knob' && (
|
|
92
|
+
<span className="absolute top-1/2 left-3.5 grid h-7 w-7 -translate-1/2 place-items-center rounded-full bg-media-handle text-media-handle-text shadow-media-handle">
|
|
93
|
+
<Icon icon={ChevronsLeftRight} size={14} />
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import { Maximize, Minus, Plus, Scan } from 'lucide-react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { ButtonGroup } from './ButtonGroup.tsx';
|
|
5
|
+
import { Icon } from './Icon.tsx';
|
|
6
|
+
import { IconButton } from './IconButton.tsx';
|
|
7
|
+
import { Kbd } from './Kbd.tsx';
|
|
8
|
+
import { MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuRoot, MenuSeparator, MenuTrigger } from './menu/parts.tsx';
|
|
9
|
+
import type { Shortcut } from './shortcut.ts';
|
|
10
|
+
import { Tooltip } from './Tooltip.tsx';
|
|
11
|
+
import { activeZoomPreset, steppedZoom, ZOOM_PRESETS } from './zoom.ts';
|
|
12
|
+
|
|
13
|
+
export interface ZoomSelection {
|
|
14
|
+
label: string;
|
|
15
|
+
shortcut?: Shortcut;
|
|
16
|
+
/* Nothing is selected, so the row is there but says so rather than doing nothing. */
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
onZoom(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* The words, when the app wants its own; each falls back to the library's. */
|
|
22
|
+
export interface ZoomLabels {
|
|
23
|
+
out?: string;
|
|
24
|
+
in?: string;
|
|
25
|
+
presets?: string;
|
|
26
|
+
fit?: string;
|
|
27
|
+
fitEverything?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ZoomControlsProps {
|
|
31
|
+
/* 1 is 100%. */
|
|
32
|
+
zoom: number;
|
|
33
|
+
onZoomChange(zoom: number): void;
|
|
34
|
+
onFitAll(): void;
|
|
35
|
+
/* The percentages in the menu under the readout. */
|
|
36
|
+
presets?: readonly number[];
|
|
37
|
+
labels?: ZoomLabels;
|
|
38
|
+
shortcuts?: { zoomReset?: Shortcut; fitAll?: Shortcut };
|
|
39
|
+
/* Left out by a surface with nothing to select. */
|
|
40
|
+
selection?: ZoomSelection;
|
|
41
|
+
className?: string;
|
|
42
|
+
ref?: Ref<HTMLDivElement>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The zoom group of a dock: out, the readout with its presets, in, and fit everything. */
|
|
46
|
+
export function ZoomControls({
|
|
47
|
+
zoom,
|
|
48
|
+
onZoomChange,
|
|
49
|
+
onFitAll,
|
|
50
|
+
presets = ZOOM_PRESETS,
|
|
51
|
+
labels = {},
|
|
52
|
+
shortcuts = {},
|
|
53
|
+
selection,
|
|
54
|
+
className,
|
|
55
|
+
ref
|
|
56
|
+
}: ZoomControlsProps) {
|
|
57
|
+
const { t } = useTranslation('ui');
|
|
58
|
+
const preset = activeZoomPreset(zoom, presets);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<ButtonGroup ref={ref} className={className}>
|
|
62
|
+
<IconButton icon={Minus} label={labels.out ?? t('zoom.out')} onClick={() => onZoomChange(steppedZoom(zoom, -10))} />
|
|
63
|
+
<MenuRoot>
|
|
64
|
+
<Tooltip label={labels.presets ?? t('zoom.presets')}>
|
|
65
|
+
<MenuTrigger className="h-8 min-w-14 rounded-lg px-1 text-xs text-text-muted tabular-nums hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text">
|
|
66
|
+
{Math.round(zoom * 100)}%
|
|
67
|
+
</MenuTrigger>
|
|
68
|
+
</Tooltip>
|
|
69
|
+
<MenuPopup side="top" sideOffset={10} align="center" className="min-w-44">
|
|
70
|
+
<MenuRadioGroup value={preset} onValueChange={(value: number) => onZoomChange(value / 100)}>
|
|
71
|
+
{presets.map((percent) => (
|
|
72
|
+
<MenuRadioItem key={percent} value={percent}>
|
|
73
|
+
<span className="tabular-nums">{percent}%</span>
|
|
74
|
+
{percent === 100 && shortcuts.zoomReset && <Kbd shortcut={shortcuts.zoomReset} />}
|
|
75
|
+
</MenuRadioItem>
|
|
76
|
+
))}
|
|
77
|
+
</MenuRadioGroup>
|
|
78
|
+
<MenuSeparator />
|
|
79
|
+
<MenuItem onClick={onFitAll}>
|
|
80
|
+
<span className="grid h-4 w-4 place-items-center">
|
|
81
|
+
<Icon icon={Maximize} size={14} />
|
|
82
|
+
</span>
|
|
83
|
+
{labels.fit ?? t('zoom.fit')} {shortcuts.fitAll && <Kbd shortcut={shortcuts.fitAll} />}
|
|
84
|
+
</MenuItem>
|
|
85
|
+
{selection && (
|
|
86
|
+
<MenuItem disabled={!selection.enabled} onClick={selection.onZoom}>
|
|
87
|
+
<span className="grid h-4 w-4 place-items-center">
|
|
88
|
+
<Icon icon={Scan} size={14} />
|
|
89
|
+
</span>
|
|
90
|
+
{selection.label} {selection.shortcut && <Kbd shortcut={selection.shortcut} />}
|
|
91
|
+
</MenuItem>
|
|
92
|
+
)}
|
|
93
|
+
</MenuPopup>
|
|
94
|
+
</MenuRoot>
|
|
95
|
+
<IconButton icon={Plus} label={labels.in ?? t('zoom.in')} onClick={() => onZoomChange(steppedZoom(zoom, 10))} />
|
|
96
|
+
<IconButton icon={Maximize} label={labels.fitEverything ?? t('zoom.fitEverything')} kbd={shortcuts.fitAll} onClick={onFitAll} />
|
|
97
|
+
</ButtonGroup>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
|
|
3
|
+
/* A Base UI `className`: a string, or a function of the part's state. */
|
|
4
|
+
export type StateClassName<State> = string | ((state: State) => string | undefined) | undefined;
|
|
5
|
+
|
|
6
|
+
/* Puts the library's own classes before a caller's, whichever form the caller's takes. */
|
|
7
|
+
export const withClass = <State>(own: string, className: StateClassName<State>): string | ((state: State) => string) =>
|
|
8
|
+
typeof className === 'function' ? (state: State) => clsx(own, className(state)) : clsx(own, className);
|