@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,108 @@
|
|
|
1
|
+
import { Fragment, useId, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import type { LucideIcon } from 'lucide-react';
|
|
4
|
+
import { Radio } from '@base-ui-components/react/radio';
|
|
5
|
+
import { RadioGroup } from '@base-ui-components/react/radio-group';
|
|
6
|
+
import { Icon } from './Icon.tsx';
|
|
7
|
+
|
|
8
|
+
export interface Choice<Value extends string | number> {
|
|
9
|
+
value: Value;
|
|
10
|
+
title: string;
|
|
11
|
+
/* One line under the title that says what picking it means. */
|
|
12
|
+
description: string;
|
|
13
|
+
icon?: LucideIcon;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChoiceCardsProps<Value extends string | number> {
|
|
18
|
+
value: Value;
|
|
19
|
+
onValueChange(value: Value): void;
|
|
20
|
+
choices: readonly Choice<Value>[];
|
|
21
|
+
/* What the group picks, read by a screen reader since the row has no visible heading of its own. */
|
|
22
|
+
label: string;
|
|
23
|
+
/* Side by side at the same width, or one under the other for choices whose descriptions run long. */
|
|
24
|
+
orientation?: 'horizontal' | 'vertical';
|
|
25
|
+
/* A set number of columns, filled row by row, for more choices than one row has room for; takes the place of `orientation`. */
|
|
26
|
+
columns?: number;
|
|
27
|
+
/* Where the radio stands in a card: after the text, or before the icon and the title. */
|
|
28
|
+
radio?: 'start' | 'end';
|
|
29
|
+
/* What the checked choice still asks for, such as a folder or an address, right under its card; only a vertical group without columns has room for it. */
|
|
30
|
+
detail?: ReactNode;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
className?: string;
|
|
33
|
+
ref?: Ref<HTMLDivElement>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ChoiceCard<Value extends string | number>({ choice, radio }: { choice: Choice<Value>; radio: 'start' | 'end' }) {
|
|
37
|
+
const id = useId();
|
|
38
|
+
// As tall as the title's line, so the dot centers on it.
|
|
39
|
+
const dot = (
|
|
40
|
+
<span className="flex h-(--text-sm--line-height) shrink-0 items-center" aria-hidden>
|
|
41
|
+
<span className="size-4 rounded-full border border-border-strong group-data-checked:border-5 group-data-checked:border-accent group-data-checked:bg-accent-text" />
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
return (
|
|
45
|
+
<Radio.Root
|
|
46
|
+
value={choice.value}
|
|
47
|
+
disabled={choice.disabled}
|
|
48
|
+
aria-labelledby={`${id}-title`}
|
|
49
|
+
aria-describedby={`${id}-description`}
|
|
50
|
+
className="group focus-ring flex min-w-0 items-start gap-3 rounded-xl border border-border bg-surface p-3 text-left hover:bg-surface-hover data-checked:border-accent data-disabled:opacity-50 data-disabled:hover:bg-surface"
|
|
51
|
+
>
|
|
52
|
+
{radio === 'start' && dot}
|
|
53
|
+
{choice.icon && (
|
|
54
|
+
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-sunken text-text-muted group-data-checked:text-accent">
|
|
55
|
+
<Icon icon={choice.icon} size={16} />
|
|
56
|
+
</span>
|
|
57
|
+
)}
|
|
58
|
+
<span className="flex min-w-0 grow flex-col gap-0.5">
|
|
59
|
+
<span id={`${id}-title`} className="text-sm font-medium text-text">
|
|
60
|
+
{choice.title}
|
|
61
|
+
</span>
|
|
62
|
+
<span id={`${id}-description`} className="text-xs text-pretty text-text-muted">
|
|
63
|
+
{choice.description}
|
|
64
|
+
</span>
|
|
65
|
+
</span>
|
|
66
|
+
{radio === 'end' && dot}
|
|
67
|
+
</Radio.Root>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* One of a few options, each a card that says what it means, for a choice that deserves more than a
|
|
73
|
+
* select: the cards stand side by side at the same width, one under the other or in a set number of
|
|
74
|
+
* columns, and the arrow keys move between them.
|
|
75
|
+
*/
|
|
76
|
+
export function ChoiceCards<Value extends string | number>({
|
|
77
|
+
value,
|
|
78
|
+
onValueChange,
|
|
79
|
+
choices,
|
|
80
|
+
label,
|
|
81
|
+
orientation = 'horizontal',
|
|
82
|
+
columns,
|
|
83
|
+
radio = 'end',
|
|
84
|
+
detail,
|
|
85
|
+
disabled,
|
|
86
|
+
className,
|
|
87
|
+
ref
|
|
88
|
+
}: ChoiceCardsProps<Value>) {
|
|
89
|
+
const vertical = columns === undefined && orientation === 'vertical';
|
|
90
|
+
return (
|
|
91
|
+
<RadioGroup
|
|
92
|
+
ref={ref}
|
|
93
|
+
value={value}
|
|
94
|
+
onValueChange={(next) => onValueChange(next as Value)}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
aria-label={label}
|
|
97
|
+
className={clsx('grid gap-2', columns !== undefined || vertical ? 'grid-flow-row' : 'auto-cols-fr grid-flow-col', className)}
|
|
98
|
+
style={columns !== undefined ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
|
99
|
+
>
|
|
100
|
+
{choices.map((choice) => (
|
|
101
|
+
<Fragment key={choice.value}>
|
|
102
|
+
<ChoiceCard choice={choice} radio={radio} />
|
|
103
|
+
{vertical && choice.value === value && detail}
|
|
104
|
+
</Fragment>
|
|
105
|
+
))}
|
|
106
|
+
</RadioGroup>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { Dialog } from '@base-ui-components/react/dialog';
|
|
4
|
+
import { IconButton, type IconButtonSize } from './IconButton.tsx';
|
|
5
|
+
import type { Shortcut } from './shortcut.ts';
|
|
6
|
+
|
|
7
|
+
export type CloseButtonProps = {
|
|
8
|
+
label: string;
|
|
9
|
+
kbd?: Shortcut | string;
|
|
10
|
+
size?: IconButtonSize;
|
|
11
|
+
className?: string;
|
|
12
|
+
ref?: Ref<HTMLButtonElement>;
|
|
13
|
+
} & ({ onClick: () => void } | { dialog: true });
|
|
14
|
+
|
|
15
|
+
/* The close button in the header of every panel and dialog. In a dialog it is that dialog's own close part. */
|
|
16
|
+
export function CloseButton({ label, kbd, size, className, ref, ...closes }: CloseButtonProps) {
|
|
17
|
+
if ('dialog' in closes) {
|
|
18
|
+
return <IconButton ref={ref} icon={X} label={label} kbd={kbd} size={size} className={className} render={<Dialog.Close />} />;
|
|
19
|
+
}
|
|
20
|
+
return <IconButton ref={ref} icon={X} label={label} kbd={kbd} size={size} className={className} onClick={closes.onClick} />;
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
5
|
+
import { Icon } from './Icon.tsx';
|
|
6
|
+
|
|
7
|
+
/* `focus-ring` for the swatch that is a menu item rather than a button, which the keyboard reaches all the same. */
|
|
8
|
+
const SWATCH = 'focus-ring grid h-6 w-6 place-items-center rounded-full text-accent-text';
|
|
9
|
+
|
|
10
|
+
/* The ring of the picked color is offset against whatever the swatch stands on. */
|
|
11
|
+
const PICKED_RING = {
|
|
12
|
+
surface: 'ring-2 ring-accent ring-offset-2 ring-offset-surface',
|
|
13
|
+
popup: 'ring-2 ring-accent ring-offset-1 ring-offset-surface-raised'
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export interface ColorSwatchProps extends ComponentProps<'button'> {
|
|
17
|
+
/* Any CSS color. Without one the swatch is an outlined circle: "no color", or the button that opens the rest of a palette. */
|
|
18
|
+
color?: string;
|
|
19
|
+
picked?: boolean;
|
|
20
|
+
/* On the surface behind a popup, or inside the popup itself. */
|
|
21
|
+
on?: keyof typeof PICKED_RING;
|
|
22
|
+
/* Another element to be the swatch, such as a menu trigger, or `<Menu.Item unstyled />` so the row's padding and highlight stay off it. */
|
|
23
|
+
render?: useRender.RenderProp;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* One color, drawn as the color itself. Its name belongs in a tooltip, so the circle carries a tick
|
|
28
|
+
* alone when it is picked. Children replace the tick, such as the ellipsis of a "more colors" swatch.
|
|
29
|
+
*/
|
|
30
|
+
export function ColorSwatch({ color, picked = false, on = 'surface', render, className, style, ref, children, ...props }: ColorSwatchProps) {
|
|
31
|
+
return useRender({
|
|
32
|
+
render: render ?? <button type="button" />,
|
|
33
|
+
ref,
|
|
34
|
+
props: {
|
|
35
|
+
...props,
|
|
36
|
+
className: clsx(SWATCH, color === undefined ? 'border border-border-strong text-text-muted' : picked && PICKED_RING[on], className),
|
|
37
|
+
style: color === undefined ? style : { ...style, background: color },
|
|
38
|
+
children: children ?? (picked && <Icon icon={Check} size={12} />)
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PointerEvent as ReactPointerEvent, Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import type { ColumnEdge } from './useColumnResize.ts';
|
|
4
|
+
|
|
5
|
+
export interface ColumnResizeHandleProps {
|
|
6
|
+
/* The edge the column hangs from, as handed to `useColumnResize`; the handle sits on the other one. */
|
|
7
|
+
from: ColumnEdge;
|
|
8
|
+
onPointerDown(event: ReactPointerEvent<HTMLElement>): void;
|
|
9
|
+
className?: string;
|
|
10
|
+
ref?: Ref<HTMLDivElement>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PLACEMENT: Record<ColumnEdge, string> = {
|
|
14
|
+
left: 'inset-y-0 right-0 w-2 cursor-col-resize',
|
|
15
|
+
right: 'inset-y-0 left-0 w-2 cursor-col-resize',
|
|
16
|
+
top: 'inset-x-0 bottom-0 h-2 cursor-row-resize',
|
|
17
|
+
bottom: 'inset-x-0 top-0 h-2 cursor-row-resize'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/* An invisible strip over the free edge of a column, so the column needs `relative` and draws its own border. */
|
|
21
|
+
export function ColumnResizeHandle({ from, onPointerDown, className, ref }: ColumnResizeHandleProps) {
|
|
22
|
+
return <div ref={ref} className={clsx('absolute z-10', PLACEMENT[from], className)} onPointerDown={onPointerDown} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { Tooltip } from './Tooltip.tsx';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Why a row cannot be picked, in a tooltip beside it rather than in the row itself. A reason is a
|
|
6
|
+
* sentence, and a sentence in a menu row sets the width of the whole menu. A row with nothing in its
|
|
7
|
+
* way is handed through untouched, so it carries no tooltip at all.
|
|
8
|
+
*/
|
|
9
|
+
export interface DisabledReasonProps {
|
|
10
|
+
reason: string | null;
|
|
11
|
+
children: ReactElement<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DisabledReason({ reason, children }: DisabledReasonProps) {
|
|
15
|
+
if (reason === null) {
|
|
16
|
+
return children;
|
|
17
|
+
}
|
|
18
|
+
return (
|
|
19
|
+
<Tooltip label={reason} side="right">
|
|
20
|
+
{children}
|
|
21
|
+
</Tooltip>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type HTMLAttributes, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Surface } from './Surface.tsx';
|
|
4
|
+
|
|
5
|
+
/* How close to the bottom of the window the pointer has to come before a hidden dock slides back. */
|
|
6
|
+
const REVEAL_ZONE = 120;
|
|
7
|
+
|
|
8
|
+
export interface DockShellProps extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/* The dock waits below the window's edge until the pointer comes down to it. */
|
|
11
|
+
autoHide?: boolean;
|
|
12
|
+
/* Classes for the bar itself, such as letting a dock with many buttons wrap. */
|
|
13
|
+
barClassName?: string;
|
|
14
|
+
/* Told whenever the bar slides away or comes back, for what stands on top of it. */
|
|
15
|
+
onHiddenChange?: (hidden: boolean) => void;
|
|
16
|
+
ref?: Ref<HTMLDivElement>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* The floating bar at the bottom of a canvas-like view, and the one place that knows how to get out
|
|
21
|
+
* of the way. With `autoHide` it waits below the edge until the pointer comes down to it, an open
|
|
22
|
+
* menu keeps it up (it would take its own popup with it), and a keyboard reaches it by tabbing. The
|
|
23
|
+
* buttons stay in the tab order while it is out of sight, so focus brings it back. Something that
|
|
24
|
+
* moves with the dock marks itself `data-holds-dock`, so reaching for it does not hide the dock.
|
|
25
|
+
*/
|
|
26
|
+
export function DockShell({ children, autoHide = false, className, barClassName, onHiddenChange, ref, ...rest }: DockShellProps) {
|
|
27
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const [revealed, setRevealed] = useState(false);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!autoHide) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let pointerNear = false;
|
|
35
|
+
const evaluate = (): void => {
|
|
36
|
+
const bar = barRef.current;
|
|
37
|
+
const holds = bar !== null && (bar.querySelector('[data-popup-open]') !== null || bar.querySelector(':focus-visible') !== null);
|
|
38
|
+
setRevealed(pointerNear || holds);
|
|
39
|
+
};
|
|
40
|
+
const onMove = (event: PointerEvent): void => {
|
|
41
|
+
/* Something that moves with the dock would slide away under the pointer reaching for it,
|
|
42
|
+
so over one the dock stays as it is. */
|
|
43
|
+
if (event.target instanceof Element && event.target.closest('[data-holds-dock]') !== null) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
pointerNear = window.innerHeight - event.clientY <= REVEAL_ZONE;
|
|
47
|
+
evaluate();
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener('pointermove', onMove);
|
|
50
|
+
window.addEventListener('focusin', evaluate);
|
|
51
|
+
return () => {
|
|
52
|
+
window.removeEventListener('pointermove', onMove);
|
|
53
|
+
window.removeEventListener('focusin', evaluate);
|
|
54
|
+
setRevealed(false);
|
|
55
|
+
};
|
|
56
|
+
}, [autoHide]);
|
|
57
|
+
|
|
58
|
+
const hidden = autoHide && !revealed;
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
onHiddenChange?.(hidden);
|
|
61
|
+
}, [hidden, onHiddenChange]);
|
|
62
|
+
return (
|
|
63
|
+
<div ref={ref} {...rest} className={clsx('pointer-events-none absolute inset-x-0 bottom-4 flex justify-center', className)}>
|
|
64
|
+
<Surface
|
|
65
|
+
ref={barRef}
|
|
66
|
+
className={clsx(
|
|
67
|
+
'flex items-center gap-2 rounded-xl p-1 transition-[opacity,translate] duration-200',
|
|
68
|
+
barClassName,
|
|
69
|
+
hidden ? 'pointer-events-none translate-y-6 opacity-0' : 'pointer-events-auto'
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{children}
|
|
73
|
+
</Surface>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, type ReactElement, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import type { LucideIcon } from 'lucide-react';
|
|
3
|
+
import clsx from 'clsx';
|
|
4
|
+
import { Icon } from './Icon.tsx';
|
|
5
|
+
import { Spinner } from './Spinner.tsx';
|
|
6
|
+
|
|
7
|
+
// One size in a panel, a card and a dialog alike, so no caller picks its own.
|
|
8
|
+
const ICON_SIZE = 20;
|
|
9
|
+
|
|
10
|
+
export interface EmptyStateProps {
|
|
11
|
+
/* A Lucide icon, or a mark of another kind (a provider's logo) that takes a `size`. */
|
|
12
|
+
icon?: LucideIcon | ReactElement<{ size?: number }>;
|
|
13
|
+
/* A `Spinner` in place of the icon, for a state that is still loading. */
|
|
14
|
+
busy?: boolean;
|
|
15
|
+
/** @deprecated Turns a Lucide icon; use `busy`, which draws a `Spinner` in its place. */
|
|
16
|
+
spin?: boolean;
|
|
17
|
+
/* A heading above the sentence, for a state that is an outcome rather than a list with nothing in it. */
|
|
18
|
+
title?: ReactNode;
|
|
19
|
+
/* One sentence. What is missing, and what puts something there. */
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
/* The button or the shortcut that fills the space; kept to one. */
|
|
22
|
+
action?: ReactNode;
|
|
23
|
+
className?: string;
|
|
24
|
+
ref?: Ref<HTMLDivElement>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* What a list, a canvas or a thread shows before it holds anything. */
|
|
28
|
+
export function EmptyState({ icon, busy = false, spin = false, title, children, action, className, ref }: EmptyStateProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div ref={ref} className={clsx('flex flex-col items-center justify-center gap-2 px-6 py-8 text-center', className)}>
|
|
31
|
+
{(busy || icon) && (
|
|
32
|
+
<span className="text-text-faint">
|
|
33
|
+
{busy ? (
|
|
34
|
+
<Spinner size={ICON_SIZE} />
|
|
35
|
+
) : isValidElement(icon) ? (
|
|
36
|
+
cloneElement(icon, { size: ICON_SIZE })
|
|
37
|
+
) : (
|
|
38
|
+
icon && <Icon icon={icon} size={ICON_SIZE} className={spin ? 'animate-spin' : undefined} />
|
|
39
|
+
)}
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
{title && <p className="text-sm font-medium text-text">{title}</p>}
|
|
43
|
+
<p className="max-w-[280px] text-xs leading-snug text-text-muted">{children}</p>
|
|
44
|
+
{/* The button is an answer to the sentence, not a third line of it. */}
|
|
45
|
+
{action && <div className="mt-2">{action}</div>}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Copy, RotateCw, TriangleAlert } from 'lucide-react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Button } from './Button.tsx';
|
|
6
|
+
import { ButtonGroup } from './ButtonGroup.tsx';
|
|
7
|
+
import { copyText } from './clipboard.ts';
|
|
8
|
+
import { errorMessageOf, errorReport, shouldReset, type ResetKeys } from './error-boundary.ts';
|
|
9
|
+
import { Icon } from './Icon.tsx';
|
|
10
|
+
import { IconButton } from './IconButton.tsx';
|
|
11
|
+
|
|
12
|
+
export interface ErrorBoundaryProps {
|
|
13
|
+
/* What failed, as the first line of the message: "This view failed to render". */
|
|
14
|
+
label: string;
|
|
15
|
+
/* What the children draw from; the boundary tries again when one of them changes. */
|
|
16
|
+
resetKeys?: ResetKeys;
|
|
17
|
+
/* A small surface, so its message drops the icon and the spacing. */
|
|
18
|
+
compact?: boolean;
|
|
19
|
+
/* The last resort has nothing around it to fall back to, so it offers a reload as well. */
|
|
20
|
+
reload?: boolean;
|
|
21
|
+
/* The box of the message; by default it covers the positioned parent. */
|
|
22
|
+
className?: string;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ErrorBoundaryState {
|
|
27
|
+
error: unknown;
|
|
28
|
+
failed: boolean;
|
|
29
|
+
componentStack: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CLEAR: ErrorBoundaryState = { error: null, failed: false, componentStack: null };
|
|
33
|
+
|
|
34
|
+
interface FallbackProps {
|
|
35
|
+
label: string;
|
|
36
|
+
error: unknown;
|
|
37
|
+
componentStack: string | null;
|
|
38
|
+
compact: boolean;
|
|
39
|
+
reload: boolean;
|
|
40
|
+
className: string;
|
|
41
|
+
onRetry(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* A function component, so the words come from the i18next instance the app renders with. */
|
|
45
|
+
function ErrorFallback({ label, error, componentStack, compact, reload, className, onRetry }: FallbackProps) {
|
|
46
|
+
const { t } = useTranslation('ui');
|
|
47
|
+
return (
|
|
48
|
+
<div role="alert" className={clsx('grid place-items-center overflow-auto bg-surface', compact ? 'p-3' : 'p-6', className)}>
|
|
49
|
+
<div className={clsx('flex max-w-[360px] flex-col items-center text-center', compact ? 'gap-1' : 'gap-2')}>
|
|
50
|
+
{!compact && <Icon icon={TriangleAlert} size={20} className="text-status-error" />}
|
|
51
|
+
<p className="text-sm font-medium text-text">{label}</p>
|
|
52
|
+
<p className="line-clamp-4 text-xs break-words text-text-muted">{errorMessageOf(error)}</p>
|
|
53
|
+
<div className={clsx('flex items-center gap-2', compact ? 'mt-1' : 'mt-2')}>
|
|
54
|
+
<Button variant="secondary" size="sm" onClick={onRetry}>
|
|
55
|
+
{t('action.retry')}
|
|
56
|
+
</Button>
|
|
57
|
+
{reload && (
|
|
58
|
+
<Button variant="secondary" size="sm" onClick={() => window.location.reload()}>
|
|
59
|
+
<Icon icon={RotateCw} size={14} />
|
|
60
|
+
{t('action.reload')}
|
|
61
|
+
</Button>
|
|
62
|
+
)}
|
|
63
|
+
<ButtonGroup>
|
|
64
|
+
<IconButton icon={Copy} size="sm" label={t('action.copyError')} onClick={() => copyText(errorReport(label, error, componentStack))} />
|
|
65
|
+
</ButtonGroup>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
* Keeps a render failure to the subtree it happened in. Without one React unmounts the whole tree,
|
|
74
|
+
* and whatever lives in it (an embedded page, a running terminal) goes down with it. Key it so it
|
|
75
|
+
* resets only while it holds an error: `resetKeys` for what the children draw from.
|
|
76
|
+
*/
|
|
77
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
78
|
+
state: ErrorBoundaryState = CLEAR;
|
|
79
|
+
|
|
80
|
+
static getDerivedStateFromError(error: unknown): Partial<ErrorBoundaryState> {
|
|
81
|
+
return { error, failed: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
85
|
+
console.error(`${this.props.label}:`, error, info.componentStack);
|
|
86
|
+
this.setState({ componentStack: info.componentStack ?? null });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
componentDidUpdate(previous: ErrorBoundaryProps): void {
|
|
90
|
+
if (shouldReset(this.state.failed, previous.resetKeys ?? [], this.props.resetKeys ?? [])) {
|
|
91
|
+
this.reset();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
reset(): void {
|
|
96
|
+
this.setState(CLEAR);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
render(): ReactNode {
|
|
100
|
+
if (!this.state.failed) {
|
|
101
|
+
return this.props.children;
|
|
102
|
+
}
|
|
103
|
+
const { label, compact = false, reload = false, className = 'absolute inset-0' } = this.props;
|
|
104
|
+
return (
|
|
105
|
+
<ErrorFallback
|
|
106
|
+
label={label}
|
|
107
|
+
error={this.state.error}
|
|
108
|
+
componentStack={this.state.componentStack}
|
|
109
|
+
compact={compact}
|
|
110
|
+
reload={reload}
|
|
111
|
+
className={className}
|
|
112
|
+
onRetry={() => this.reset()}
|
|
113
|
+
/>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/Field.tsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useId, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
4
|
+
import { FieldContext } from './field-context.ts';
|
|
5
|
+
import { SectionLabel } from './SectionLabel.tsx';
|
|
6
|
+
|
|
7
|
+
const HINT = 'text-xs text-text-muted';
|
|
8
|
+
const ERROR = 'text-xs text-status-error';
|
|
9
|
+
// Beside the control the label reads at the size of what is typed, on the middle of the control's first line however tall it grows.
|
|
10
|
+
const LABEL_BESIDE = 'flex h-8 items-center self-start text-sm text-text-muted';
|
|
11
|
+
|
|
12
|
+
export type FieldHintProps = useRender.ComponentProps<'p'>;
|
|
13
|
+
|
|
14
|
+
/* The line under a field that says what goes in it. */
|
|
15
|
+
export function FieldHint({ render, className, ref, ...props }: FieldHintProps) {
|
|
16
|
+
return useRender({
|
|
17
|
+
render,
|
|
18
|
+
ref,
|
|
19
|
+
defaultTagName: 'p',
|
|
20
|
+
props: { ...props, className: clsx('mt-1', HINT, className) }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FormErrorProps = useRender.ComponentProps<'p'>;
|
|
25
|
+
|
|
26
|
+
/* What went wrong in a dialog or a form. An alert, so a screen reader hears it arrive. */
|
|
27
|
+
export function FormError({ render, className, ref, ...props }: FormErrorProps) {
|
|
28
|
+
return useRender({
|
|
29
|
+
render,
|
|
30
|
+
ref,
|
|
31
|
+
defaultTagName: 'p',
|
|
32
|
+
props: { role: 'alert', ...props, className: clsx(ERROR, className) }
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* A group's label names it through `aria-labelledby`; a control's points at the control. */
|
|
37
|
+
function FieldLabel({ id, group, horizontal, children }: { id: string; group: boolean; horizontal: boolean; children: ReactNode }) {
|
|
38
|
+
if (horizontal) {
|
|
39
|
+
return group ? (
|
|
40
|
+
<span id={`${id}-label`} className={LABEL_BESIDE}>
|
|
41
|
+
{children}
|
|
42
|
+
</span>
|
|
43
|
+
) : (
|
|
44
|
+
<label htmlFor={`${id}-control`} className={LABEL_BESIDE}>
|
|
45
|
+
{children}
|
|
46
|
+
</label>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return group ? (
|
|
50
|
+
<SectionLabel id={`${id}-label`}>{children}</SectionLabel>
|
|
51
|
+
) : (
|
|
52
|
+
<SectionLabel render={<label htmlFor={`${id}-control`} />}>{children}</SectionLabel>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FieldProps {
|
|
57
|
+
label?: ReactNode;
|
|
58
|
+
hint?: ReactNode;
|
|
59
|
+
/* Shown under the control and marks it invalid; null or empty shows nothing. */
|
|
60
|
+
error?: ReactNode;
|
|
61
|
+
/*
|
|
62
|
+
* For what a `<label>` cannot point at: a `Segmented`, a `Select`, cards to choose from, a path
|
|
63
|
+
* in a box with a button beside it. The label then names a group around it, which the hint and
|
|
64
|
+
* the error describe.
|
|
65
|
+
*/
|
|
66
|
+
group?: boolean;
|
|
67
|
+
/* `horizontal` puts the label in a column of its own beside the control, for a form in a dialog where a stack of fields would scroll. */
|
|
68
|
+
orientation?: 'vertical' | 'horizontal';
|
|
69
|
+
className?: string;
|
|
70
|
+
ref?: Ref<HTMLDivElement>;
|
|
71
|
+
/* The control. An `Input` or a `TextArea` connects to the label and the lines under it by itself, unless the field is a `group`. */
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
* A labelled control, or a labelled group, with an optional hint and error under it, stacked 6px apart.
|
|
77
|
+
* Beside each other the label column is 112px, so the controls of every field in a form line up.
|
|
78
|
+
*/
|
|
79
|
+
export function Field({ label, hint, error, group = false, orientation = 'vertical', className, ref, children }: FieldProps) {
|
|
80
|
+
const horizontal = orientation === 'horizontal';
|
|
81
|
+
const id = useId();
|
|
82
|
+
const hasLabel = label !== undefined;
|
|
83
|
+
const hasHint = hint !== undefined && hint !== null && hint !== '';
|
|
84
|
+
const hasError = error !== undefined && error !== null && error !== '';
|
|
85
|
+
const describedBy = [hasHint ? `${id}-hint` : null, hasError ? `${id}-error` : null].filter((part) => part !== null).join(' ') || undefined;
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
ref={ref}
|
|
89
|
+
role={group ? 'group' : undefined}
|
|
90
|
+
aria-labelledby={group && hasLabel ? `${id}-label` : undefined}
|
|
91
|
+
aria-describedby={group ? describedBy : undefined}
|
|
92
|
+
className={clsx(horizontal ? 'grid grid-cols-[112px_minmax(0,1fr)] gap-x-3 gap-y-1.5' : 'flex flex-col gap-1.5', className)}
|
|
93
|
+
>
|
|
94
|
+
{hasLabel && (
|
|
95
|
+
<FieldLabel id={id} group={group} horizontal={horizontal}>
|
|
96
|
+
{label}
|
|
97
|
+
</FieldLabel>
|
|
98
|
+
)}
|
|
99
|
+
{/* A group describes itself, so an input inside it stays unconnected rather than taking an id nothing points at. */}
|
|
100
|
+
<FieldContext value={group ? null : { id: `${id}-control`, describedBy, invalid: hasError }}>
|
|
101
|
+
{horizontal ? <div className="col-start-2 min-w-0">{children}</div> : children}
|
|
102
|
+
</FieldContext>
|
|
103
|
+
{/* The stack already puts 6px above each line, so neither takes the margin a standalone hint has. */}
|
|
104
|
+
{hasHint && (
|
|
105
|
+
<p id={`${id}-hint`} className={clsx(HINT, horizontal && 'col-start-2')}>
|
|
106
|
+
{hint}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
{hasError && (
|
|
110
|
+
<p id={`${id}-error`} role="alert" className={clsx(ERROR, 'break-words', horizontal && 'col-start-2')}>
|
|
111
|
+
{error}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
package/src/FileIcon.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useInsertionEffect, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { fileIconFor, mountFileIconSprite } from './file-icon.ts';
|
|
4
|
+
|
|
5
|
+
export interface FileIconProps {
|
|
6
|
+
/* Absolute or relative; only the last segment decides which icon this is. */
|
|
7
|
+
path: string;
|
|
8
|
+
size?: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
ref?: Ref<SVGSVGElement>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The icon a file gets in a file tree, drawn anywhere else the same file's name shows up. It is the
|
|
15
|
+
* one place the library steps outside Lucide. The glyphs are Seti's, and so are their colors, because
|
|
16
|
+
* a TypeScript blue or a Vue green is the mark of the file type, not a theme choice. Decorative like
|
|
17
|
+
* every other icon here, so the name beside it does the reading.
|
|
18
|
+
*/
|
|
19
|
+
export function FileIcon({ path, size = 16, className, ref }: FileIconProps) {
|
|
20
|
+
// Before the browser paints, so a `<use>` never points at a symbol that is not in the document yet.
|
|
21
|
+
useInsertionEffect(mountFileIconSprite, []);
|
|
22
|
+
return (
|
|
23
|
+
<svg ref={ref} className={clsx('file-icon', className)} width={size} height={size} viewBox="0 0 16 16" aria-hidden>
|
|
24
|
+
<use href={`#${fileIconFor(path)}`} />
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
}
|
package/src/Icon.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import type { LucideIcon } from 'lucide-react';
|
|
3
|
+
import clsx from 'clsx';
|
|
4
|
+
|
|
5
|
+
// The whole client draws at this weight. 2 is Lucide's default, but at 12 and 14 pixels next to
|
|
6
|
+
// text of the same size a hair thinner keeps the glyph from outweighing the words beside it.
|
|
7
|
+
const STROKE_WIDTH = 1.75;
|
|
8
|
+
|
|
9
|
+
export interface IconProps {
|
|
10
|
+
icon: LucideIcon;
|
|
11
|
+
size?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
ref?: Ref<SVGSVGElement>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* One Lucide icon, sized in pixels. Icons here are decorative. The name a screen reader reads
|
|
17
|
+
sits on the button or the label next to it, so the glyph stays hidden. */
|
|
18
|
+
export function Icon({ icon: Glyph, size = 16, className, ref }: IconProps) {
|
|
19
|
+
// An inline svg sits on the text baseline, which drops a square glyph below the words next to
|
|
20
|
+
// it; the middle of the box against the middle of the text is what reads as aligned.
|
|
21
|
+
return <Glyph ref={ref} size={size} strokeWidth={STROKE_WIDTH} className={clsx('align-middle', className)} aria-hidden />;
|
|
22
|
+
}
|