cursedbelt 5.96.0 → 5.97.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/dist/react/components/AppShell.js +6 -1
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ConfirmDialog.d.ts.map +1 -1
- package/dist/react/components/ConfirmDialog.js +1 -1
- package/dist/react/components/ConfirmDialog.js.map +1 -1
- package/dist/react/components/PaneSplit.d.ts +11 -2
- package/dist/react/components/PaneSplit.d.ts.map +1 -1
- package/dist/react/components/PaneSplit.js +12 -11
- package/dist/react/components/PaneSplit.js.map +1 -1
- package/dist/react/components/PanelGrid.d.ts +14 -8
- package/dist/react/components/PanelGrid.d.ts.map +1 -1
- package/dist/react/components/PanelGrid.js +10 -3
- package/dist/react/components/PanelGrid.js.map +1 -1
- package/dist/react/components/SplitPane.d.ts +3 -9
- package/dist/react/components/SplitPane.d.ts.map +1 -1
- package/dist/react/components/SplitPane.js +0 -3
- package/dist/react/components/SplitPane.js.map +1 -1
- package/dist/react/components/TagsEditor.d.ts +15 -0
- package/dist/react/components/TagsEditor.d.ts.map +1 -0
- package/dist/react/components/TagsEditor.js +93 -0
- package/dist/react/components/TagsEditor.js.map +1 -0
- package/dist/react/components/TagsPromptDialog.d.ts +25 -0
- package/dist/react/components/TagsPromptDialog.d.ts.map +1 -0
- package/dist/react/components/TagsPromptDialog.js +38 -0
- package/dist/react/components/TagsPromptDialog.js.map +1 -0
- package/dist/react/components/paneSplit.css +8 -0
- package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.js +2 -0
- package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
- package/dist/react/file-tree/useFolderView.d.ts +11 -1
- package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
- package/dist/react/file-tree/useFolderView.js +3 -2
- package/dist/react/file-tree/useFolderView.js.map +1 -1
- package/dist/react/filter-rail/railResize.d.ts +16 -5
- package/dist/react/filter-rail/railResize.d.ts.map +1 -1
- package/dist/react/filter-rail/railResize.js +27 -9
- package/dist/react/filter-rail/railResize.js.map +1 -1
- package/dist/react/filter-rail/railWidth.d.ts +10 -5
- package/dist/react/filter-rail/railWidth.d.ts.map +1 -1
- package/dist/react/filter-rail/railWidth.js +13 -8
- package/dist/react/filter-rail/railWidth.js.map +1 -1
- package/dist/react/hooks/longPressContextMenu.d.ts +10 -1
- package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -1
- package/dist/react/hooks/longPressContextMenu.js +37 -3
- package/dist/react/hooks/longPressContextMenu.js.map +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/paneReach.d.ts +36 -0
- package/dist/react/lib/paneReach.d.ts.map +1 -0
- package/dist/react/lib/paneReach.js +72 -0
- package/dist/react/lib/paneReach.js.map +1 -0
- package/dist/react/lib/tagList.d.ts +16 -0
- package/dist/react/lib/tagList.d.ts.map +1 -0
- package/dist/react/lib/tagList.js +30 -0
- package/dist/react/lib/tagList.js.map +1 -0
- package/dist/react/master-detail/MasterDetail.d.ts +6 -3
- package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
- package/dist/react/master-detail/MasterDetail.js +3 -2
- package/dist/react/master-detail/MasterDetail.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts +8 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +10 -1
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +3 -1
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +10 -5
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +3 -1
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +14 -6
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +3 -1
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +14 -6
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +3 -1
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +13 -5
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +27 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +39 -0
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/videoEnhance.d.ts +1 -1
- package/dist/react/media/videoEnhance.js +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +4 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/tagsPrompt.d.ts +15 -0
- package/dist/react/tagsPrompt.d.ts.map +1 -0
- package/dist/react/tagsPrompt.js +30 -0
- package/dist/react/tagsPrompt.js.map +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/fields.css +4 -4
- package/dist/styles-areas/overlays.css +3 -2
- package/dist/styles-utilities.css +7 -0
- package/dist/testing/phoneRules.js +3 -3
- package/dist/testing/phoneRules.js.map +1 -1
- package/dist/theme.css +13 -0
- package/package.json +8 -1
- package/scripts/guardrailsEnforce.spec.ts +25 -0
- package/scripts/guardrailsEnforce.ts +61 -0
- package/scripts/styleAreas.ts +5 -0
- package/src/looks.spec.ts +34 -0
- package/src/react/components/AppShell.spec.tsx +23 -0
- package/src/react/components/AppShell.tsx +6 -1
- package/src/react/components/ConfirmDialog.spec.tsx +41 -0
- package/src/react/components/ConfirmDialog.tsx +12 -2
- package/src/react/components/PaneSplit.spec.tsx +26 -3
- package/src/react/components/PaneSplit.tsx +26 -13
- package/src/react/components/PanelGrid.tsx +16 -11
- package/src/react/components/SplitPane.spec.tsx +10 -5
- package/src/react/components/SplitPane.tsx +3 -12
- package/src/react/components/TagsEditor.tsx +140 -0
- package/src/react/components/TagsPromptDialog.spec.tsx +74 -0
- package/src/react/components/TagsPromptDialog.tsx +108 -0
- package/src/react/components/paneSplit.css +8 -0
- package/src/react/file-tree/FolderViewMenus.tsx +1 -0
- package/src/react/file-tree/folderViewTree.spec.tsx +15 -0
- package/src/react/file-tree/useFolderView.ts +13 -2
- package/src/react/filter-rail/FilterRail.spec.tsx +7 -3
- package/src/react/filter-rail/railResize.spec.tsx +27 -5
- package/src/react/filter-rail/railResize.tsx +46 -15
- package/src/react/filter-rail/railWidth.ts +13 -8
- package/src/react/hooks/longPressContextMenu.spec.ts +50 -0
- package/src/react/hooks/longPressContextMenu.ts +39 -3
- package/src/react/index.ts +4 -0
- package/src/react/lib/paneReach.spec.ts +58 -0
- package/src/react/lib/paneReach.ts +65 -0
- package/src/react/lib/tagList.spec.ts +19 -0
- package/src/react/lib/tagList.ts +29 -0
- package/src/react/master-detail/MasterDetail.tsx +10 -5
- package/src/react/media/FocusViewer.spec.tsx +19 -0
- package/src/react/media/FocusViewer.tsx +17 -0
- package/src/react/media/FrameGrabber.tsx +12 -5
- package/src/react/media/VideoChapterEditor.tsx +16 -6
- package/src/react/media/VideoCutEditor.tsx +17 -6
- package/src/react/media/VideoTurner.tsx +14 -5
- package/src/react/media/editorVideoSource.spec.tsx +71 -0
- package/src/react/media/hlsSource.ts +43 -0
- package/src/react/media/videoEnhance.ts +1 -1
- package/src/react/media-gallery/MediaGallery.tsx +4 -0
- package/src/react/tagsPrompt.tsx +33 -0
- package/src/styles-areas/fields.css +4 -4
- package/src/styles-areas/overlays.css +3 -2
- package/src/styles-utilities.css +7 -0
- package/src/testing/phoneRules.ts +2 -2
- package/src/theme.css +13 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One item's tags, edited in place — and carried whole to another. Lifted from photos (forge task
|
|
3
|
+
* 3773; the owner: "copy some or all tags from one photo to apply to more photos … a copy icon that
|
|
4
|
+
* copies all of them … highlight the existing tags with my mouse or ⌘A, then ⌘C … ⌘V to paste") so
|
|
5
|
+
* music tags a track the same way (forge task 3812: "copying tags like photos app does").
|
|
6
|
+
*
|
|
7
|
+
* Three ways out, one way in, all through the system clipboard as `a, b, c` (`../lib/tagList.ts`):
|
|
8
|
+
* - the copy button copies every tag;
|
|
9
|
+
* - ⌘A in the empty field selects every tag (the field lights up and says so), and ⌘C copies them;
|
|
10
|
+
* - a mouse selection across some chips, then ⌘C, copies just those — the chips are plain text;
|
|
11
|
+
* - a paste into the field adds every tag in the text, however the list was separated, at once.
|
|
12
|
+
*/
|
|
13
|
+
import { Copy } from 'lucide-react';
|
|
14
|
+
import { type ClipboardEvent, type ComponentPropsWithoutRef, type KeyboardEvent, useEffect, useState } from 'react';
|
|
15
|
+
import { toast } from '../Toast.js';
|
|
16
|
+
import { cn } from '../lib/cn.js';
|
|
17
|
+
import { mergeTagLists, parseTagList, tagListText } from '../lib/tagList.js';
|
|
18
|
+
import { IconButton } from './IconButton.js';
|
|
19
|
+
import { TagsInput } from './TagsInput.js';
|
|
20
|
+
|
|
21
|
+
export interface TagsEditorProps
|
|
22
|
+
extends Omit<ComponentPropsWithoutRef<'div'>, 'onChange' | 'defaultValue' | 'aria-label' | 'className' | 'onCopy' | 'onPaste' | 'onKeyDown' | 'onPointerDown'> {
|
|
23
|
+
value: string[];
|
|
24
|
+
onChange: (next: string[]) => void;
|
|
25
|
+
suggestions?: readonly string[];
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
'aria-label'?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
/** The app's one spelling rule for a pasted tag (photos lower-cases and folds synonyms). Default: trim. */
|
|
30
|
+
normalize?: (raw: string) => string;
|
|
31
|
+
/** What the tags are on, for the copy toast and tooltip: "paste them onto another {item}". Default `item`. */
|
|
32
|
+
item?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MOD = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl+';
|
|
36
|
+
|
|
37
|
+
const tagCount = (n: number): string => `${n.toLocaleString()} ${n === 1 ? 'tag' : 'tags'}`;
|
|
38
|
+
|
|
39
|
+
/** The chips a text selection touches — a chip is the span whose first child is the tag's text and whose last is its ✕. */
|
|
40
|
+
function selectedChips(container: HTMLElement, selection: Selection | null): string[] {
|
|
41
|
+
if (!selection || selection.isCollapsed) return [];
|
|
42
|
+
const picked: string[] = [];
|
|
43
|
+
for (const span of container.querySelectorAll<HTMLSpanElement>('span')) {
|
|
44
|
+
const text = span.firstChild;
|
|
45
|
+
if (!text || text.nodeType !== Node.TEXT_NODE || !span.querySelector('button')) continue;
|
|
46
|
+
if (selection.containsNode(span, true)) picked.push(text.textContent ?? '');
|
|
47
|
+
}
|
|
48
|
+
return picked.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function TagsEditor({
|
|
52
|
+
value,
|
|
53
|
+
onChange,
|
|
54
|
+
suggestions,
|
|
55
|
+
placeholder = 'Add a tag',
|
|
56
|
+
'aria-label': ariaLabel = 'Tags',
|
|
57
|
+
className,
|
|
58
|
+
normalize,
|
|
59
|
+
item = 'item',
|
|
60
|
+
...rest
|
|
61
|
+
}: TagsEditorProps) {
|
|
62
|
+
// Every chip selected at once (⌘A on the empty field) — drawn as a ring and a line, since the chips are not selectable text then.
|
|
63
|
+
const [all, setAll] = useState(false);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (all && !value.length) setAll(false);
|
|
66
|
+
}, [all, value.length]);
|
|
67
|
+
|
|
68
|
+
const copied = (count: number): void => {
|
|
69
|
+
toast.success(`Copied ${tagCount(count)} — paste them onto another ${item}`);
|
|
70
|
+
};
|
|
71
|
+
const copyAll = async (): Promise<void> => {
|
|
72
|
+
try {
|
|
73
|
+
await navigator.clipboard.writeText(tagListText(value));
|
|
74
|
+
copied(value.length);
|
|
75
|
+
} catch {
|
|
76
|
+
toast.error(`Couldn't reach the clipboard — select the tags and press ${MOD}C instead`);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
80
|
+
const target = event.target as HTMLElement;
|
|
81
|
+
const field = target.tagName === 'INPUT' ? (target as HTMLInputElement) : null;
|
|
82
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
83
|
+
if (mod && event.key.toLowerCase() === 'a' && field && !field.value && value.length) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
setAll(true);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (mod && event.key.toLowerCase() === 'c') return;
|
|
89
|
+
if (all && event.key !== 'Shift' && event.key !== 'Meta' && event.key !== 'Control') setAll(false);
|
|
90
|
+
};
|
|
91
|
+
const onCopy = (event: ClipboardEvent<HTMLDivElement>): void => {
|
|
92
|
+
const picked = all ? value : selectedChips(event.currentTarget, window.getSelection());
|
|
93
|
+
if (!picked.length) return;
|
|
94
|
+
event.clipboardData.setData('text/plain', tagListText(picked));
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
copied(picked.length);
|
|
97
|
+
};
|
|
98
|
+
const onPaste = (event: ClipboardEvent<HTMLDivElement>): void => {
|
|
99
|
+
if ((event.target as HTMLElement).tagName !== 'INPUT') return;
|
|
100
|
+
const pasted = parseTagList(event.clipboardData.getData('text/plain'), normalize);
|
|
101
|
+
if (!pasted.length) return;
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
const next = mergeTagLists(value, pasted);
|
|
104
|
+
if (next.length !== value.length) onChange(next);
|
|
105
|
+
else toast.success(`Every pasted tag is on it already`);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: the keys, copy and paste are the field's inside it; the div only listens as they bubble.
|
|
110
|
+
<div
|
|
111
|
+
{...rest}
|
|
112
|
+
className={cn('flex flex-col gap-1', className)}
|
|
113
|
+
data-tags-editor=''
|
|
114
|
+
onKeyDown={onKeyDown}
|
|
115
|
+
onCopy={onCopy}
|
|
116
|
+
onPaste={onPaste}
|
|
117
|
+
onPointerDown={() => all && setAll(false)}
|
|
118
|
+
>
|
|
119
|
+
<div className='flex items-start gap-1'>
|
|
120
|
+
<div className={cn('min-w-0 flex-1 rounded-field', all && 'ring-2 ring-accent ring-offset-2 ring-offset-background')}>
|
|
121
|
+
<TagsInput value={value} onChange={onChange} placeholder={placeholder} aria-label={ariaLabel} suggestions={suggestions} />
|
|
122
|
+
</div>
|
|
123
|
+
<IconButton
|
|
124
|
+
label='Copy all tags'
|
|
125
|
+
tooltip={value.length ? `Copy ${tagCount(value.length)} to paste onto another ${item}` : 'No tags to copy yet'}
|
|
126
|
+
variant='ghost'
|
|
127
|
+
disabled={!value.length}
|
|
128
|
+
onClick={() => void copyAll()}
|
|
129
|
+
>
|
|
130
|
+
<Copy />
|
|
131
|
+
</IconButton>
|
|
132
|
+
</div>
|
|
133
|
+
{all ? (
|
|
134
|
+
<p className='text-xs text-accent' aria-live='polite'>
|
|
135
|
+
All {tagCount(value.length)} selected — {MOD}C copies them
|
|
136
|
+
</p>
|
|
137
|
+
) : null}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
|
3
|
+
import { promptTags } from '../tagsPrompt.js';
|
|
4
|
+
import { TagsPromptDialog } from './TagsPromptDialog.js';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
const body = () => document.body;
|
|
9
|
+
const chips = () =>
|
|
10
|
+
Array.from(body().querySelectorAll<HTMLElement>('[data-tags-editor] span'))
|
|
11
|
+
.filter((span) => span.querySelector('button'))
|
|
12
|
+
.map((span) => span.firstChild?.textContent ?? '');
|
|
13
|
+
const field = () => body().querySelector<HTMLInputElement>('[data-tags-editor] input')!;
|
|
14
|
+
|
|
15
|
+
describe('TagsPromptDialog', () => {
|
|
16
|
+
test('opens on the tags the item already has, as chips (forge task 3812)', () => {
|
|
17
|
+
render(<TagsPromptDialog open title='Tag this track' defaultValue={['chill', 'night drive']} onSubmit={() => {}} onCancel={() => {}} />);
|
|
18
|
+
expect(chips()).toEqual(['chill', 'night drive']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('several at once, one off, and Save hands back the whole list', () => {
|
|
22
|
+
let saved: string[] | undefined;
|
|
23
|
+
render(
|
|
24
|
+
<TagsPromptDialog
|
|
25
|
+
open
|
|
26
|
+
title='Tag this track'
|
|
27
|
+
defaultValue={['chill']}
|
|
28
|
+
onSubmit={(tags) => {
|
|
29
|
+
saved = tags;
|
|
30
|
+
}}
|
|
31
|
+
onCancel={() => {}}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
fireEvent.change(field(), { target: { value: 'loud, summer,' } });
|
|
35
|
+
expect(chips()).toEqual(['chill', 'loud', 'summer']);
|
|
36
|
+
fireEvent.click(body().querySelector('[aria-label="Remove chill"]') ?? body().querySelectorAll('[data-tags-editor] span button')[0]!);
|
|
37
|
+
fireEvent.click(body().querySelector('[data-cb-confirm]') as HTMLElement);
|
|
38
|
+
expect(saved).toEqual(['loud', 'summer']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('⌘Enter saves with the word still being typed', () => {
|
|
42
|
+
let saved: string[] | undefined;
|
|
43
|
+
render(
|
|
44
|
+
<TagsPromptDialog
|
|
45
|
+
open
|
|
46
|
+
title='Tag'
|
|
47
|
+
onSubmit={(tags) => {
|
|
48
|
+
saved = tags;
|
|
49
|
+
}}
|
|
50
|
+
onCancel={() => {}}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
fireEvent.change(field(), { target: { value: 'dub' } });
|
|
54
|
+
fireEvent.keyDown(field(), { key: 'Enter', metaKey: true });
|
|
55
|
+
expect(saved).toEqual(['dub']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('promptTags resolves the list, or null on cancel', async () => {
|
|
59
|
+
let pending: Promise<string[] | null> | undefined;
|
|
60
|
+
act(() => {
|
|
61
|
+
pending = promptTags({ title: 'Tag', defaultValue: ['a'] });
|
|
62
|
+
});
|
|
63
|
+
await waitFor(() => expect(chips()).toEqual(['a']));
|
|
64
|
+
fireEvent.click(body().querySelector('[data-cb-confirm]') as HTMLElement);
|
|
65
|
+
expect(await pending).toEqual(['a']);
|
|
66
|
+
|
|
67
|
+
act(() => {
|
|
68
|
+
pending = promptTags({ title: 'Tag' });
|
|
69
|
+
});
|
|
70
|
+
await waitFor(() => expect(body().querySelector('[data-cb-cancel]')).not.toBeNull());
|
|
71
|
+
fireEvent.click(body().querySelector('[data-cb-cancel]') as HTMLElement);
|
|
72
|
+
expect(await pending).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type ReactNode, useState } from 'react';
|
|
2
|
+
import { cn } from '../lib/cn.js';
|
|
3
|
+
import { DIALOG_MOTION } from '../lib/overlayMotion.js';
|
|
4
|
+
import { Button } from './Button.js';
|
|
5
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog.js';
|
|
6
|
+
import { TagsEditor, type TagsEditorProps } from './TagsEditor.js';
|
|
7
|
+
|
|
8
|
+
export interface TagsPromptDialogProps extends Pick<TagsEditorProps, 'suggestions' | 'placeholder' | 'normalize' | 'item'> {
|
|
9
|
+
open: boolean;
|
|
10
|
+
/** The tags the dialog closed on — the whole list, so a removal is the difference from `defaultValue`. */
|
|
11
|
+
onSubmit: (tags: string[]) => void | Promise<void>;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
title: string;
|
|
14
|
+
message?: ReactNode;
|
|
15
|
+
/** The tags the item already carries — drawn as chips the moment it opens (forge task 3812:
|
|
16
|
+
* *"clicking 'Tag this track' doesn't show existing tags but it should"*). */
|
|
17
|
+
defaultValue?: readonly string[];
|
|
18
|
+
confirmLabel?: string;
|
|
19
|
+
cancelLabel?: string;
|
|
20
|
+
loading?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* {@link PromptDialog}'s shape with a {@link TagsEditor} for its field: the item's tags as chips,
|
|
26
|
+
* several added at once (comma/Enter, or a pasted list), × to take one off, the copy button and
|
|
27
|
+
* ⌘A/⌘C to carry them to another item. For the no-JSX case use `promptTags`
|
|
28
|
+
* (`cursedbelt/react/tags-prompt`), which mounts it once and resolves to the list or `null`.
|
|
29
|
+
*/
|
|
30
|
+
export function TagsPromptDialog({
|
|
31
|
+
open,
|
|
32
|
+
onSubmit,
|
|
33
|
+
onCancel,
|
|
34
|
+
title,
|
|
35
|
+
message,
|
|
36
|
+
defaultValue = [],
|
|
37
|
+
confirmLabel = 'Save tags',
|
|
38
|
+
cancelLabel = 'Cancel',
|
|
39
|
+
loading = false,
|
|
40
|
+
className,
|
|
41
|
+
suggestions,
|
|
42
|
+
placeholder,
|
|
43
|
+
normalize,
|
|
44
|
+
item,
|
|
45
|
+
...rest
|
|
46
|
+
}: TagsPromptDialogProps) {
|
|
47
|
+
const [tags, setTags] = useState<string[]>(() => [...defaultValue]);
|
|
48
|
+
|
|
49
|
+
/** A click on Save blurs the field first, which commits its draft; ⌘Enter does not, so it reads the draft from `content`. */
|
|
50
|
+
const handleSubmit = (content?: HTMLElement) => {
|
|
51
|
+
if (loading) return;
|
|
52
|
+
const raw = content?.querySelector<HTMLInputElement>('[data-tags-editor] input')?.value ?? '';
|
|
53
|
+
const pending = normalize ? normalize(raw) : raw.trim();
|
|
54
|
+
void onSubmit(pending && !tags.includes(pending) ? [...tags, pending] : tags);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Dialog
|
|
59
|
+
{...rest}
|
|
60
|
+
open={open}
|
|
61
|
+
onOpenChange={(v) => {
|
|
62
|
+
if (!v) onCancel();
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<DialogContent
|
|
66
|
+
data-cb-tags-prompt=''
|
|
67
|
+
className={cn(
|
|
68
|
+
'fixed top-1/2 left-1/2 flex w-[calc(100vw-2rem)] max-w-[var(--modal-w-sm)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4',
|
|
69
|
+
'rounded-xl border border-border bg-popover p-6 text-popover-foreground shadow-overlay',
|
|
70
|
+
DIALOG_MOTION,
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
onOpenAutoFocus={(e) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
(e.currentTarget as HTMLElement | null)?.querySelector<HTMLInputElement>('[data-tags-editor] input')?.focus();
|
|
76
|
+
}}
|
|
77
|
+
onKeyDown={(e) => {
|
|
78
|
+
// ⌘/Ctrl+Enter saves from the field, where a bare Enter commits the tag being typed.
|
|
79
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
handleSubmit(e.currentTarget);
|
|
82
|
+
}
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<div className='flex flex-col gap-1.5'>
|
|
86
|
+
<DialogTitle className='text-base font-semibold'>{title}</DialogTitle>
|
|
87
|
+
{message ? <DialogDescription className='text-sm text-muted-foreground'>{message}</DialogDescription> : null}
|
|
88
|
+
</div>
|
|
89
|
+
<TagsEditor
|
|
90
|
+
value={tags}
|
|
91
|
+
onChange={setTags}
|
|
92
|
+
{...(suggestions ? { suggestions } : {})}
|
|
93
|
+
{...(placeholder ? { placeholder } : {})}
|
|
94
|
+
{...(normalize ? { normalize } : {})}
|
|
95
|
+
{...(item ? { item } : {})}
|
|
96
|
+
/>
|
|
97
|
+
<div className='flex justify-end gap-2'>
|
|
98
|
+
<Button data-cb-cancel variant='secondary' onClick={onCancel} disabled={loading} tooltip={cancelLabel}>
|
|
99
|
+
{cancelLabel}
|
|
100
|
+
</Button>
|
|
101
|
+
<Button data-cb-confirm variant='primary' onClick={() => handleSubmit()} loading={loading} tooltip={confirmLabel}>
|
|
102
|
+
{confirmLabel}
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
</DialogContent>
|
|
106
|
+
</Dialog>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
min-height: 0;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/* A shut pane (dragged to nothing — `lib/paneReach.ts`) draws nothing and takes no focus; its
|
|
44
|
+
content would otherwise spill out of a zero-wide track. The seam beside it is a sibling, so it
|
|
45
|
+
stays to drag the pane open again. */
|
|
46
|
+
.cb-panesplit__cell[data-shut] {
|
|
47
|
+
visibility: hidden;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
.cb-panesplit__seam {
|
|
44
52
|
flex: 0 0 auto;
|
|
45
53
|
position: relative;
|
|
@@ -345,6 +345,21 @@ describe('a row menu of the app\'s own', () => {
|
|
|
345
345
|
expect(renamed).toBe('home');
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
test('🔴 tagging: false takes Tags… off, so the app\'s own item tags what the folder HOLDS (forge task 3813)', () => {
|
|
349
|
+
render(
|
|
350
|
+
<Harness
|
|
351
|
+
options={{ tagging: false }}
|
|
352
|
+
rowMenu={() => [{ key: 'tag-songs', label: 'Tag the songs in it…' }]}
|
|
353
|
+
/>,
|
|
354
|
+
);
|
|
355
|
+
fireEvent.contextMenu(row('Home'));
|
|
356
|
+
expect(items().map((el) => el.querySelector('.truncate')?.textContent)).toEqual([
|
|
357
|
+
'Tag the songs in it…',
|
|
358
|
+
'Hide on this device',
|
|
359
|
+
'Hide everywhere',
|
|
360
|
+
]);
|
|
361
|
+
});
|
|
362
|
+
|
|
348
363
|
test('Hide and Tags… go ABOVE a trailing Delete — Delete stays last, under its rule', () => {
|
|
349
364
|
render(
|
|
350
365
|
<Harness
|
|
@@ -69,6 +69,8 @@ export interface FolderView {
|
|
|
69
69
|
tags: readonly TagCount[];
|
|
70
70
|
/** Whether "Hide on this device" is offered — false when the device half is not kept. */
|
|
71
71
|
deviceHiding: boolean;
|
|
72
|
+
/** Whether a folder's menu offers "Tags…" — false when the app tags what is IN a folder instead. */
|
|
73
|
+
tagging: boolean;
|
|
72
74
|
/** Which of the two lists hide this folder, if any. */
|
|
73
75
|
hiddenIn: (id: string) => HiddenScope | null;
|
|
74
76
|
|
|
@@ -118,6 +120,14 @@ export interface UseFolderViewOptions {
|
|
|
118
120
|
* `'memory'` is this page only, for an app whose folder ids may not be written to disk.
|
|
119
121
|
*/
|
|
120
122
|
device?: 'local' | 'memory';
|
|
123
|
+
/**
|
|
124
|
+
* `false` takes "Tags…" off a folder's menu, for an app whose folder tags belong on what the
|
|
125
|
+
* folder holds — music, where the owner wanted *"the tags to add on a folder [to] apply to all
|
|
126
|
+
* the tracks in the folder instead of applying to the folder"* (forge task 3813). The app puts
|
|
127
|
+
* its own item on the folder's `rowMenu`; the dots of marks already kept still draw, so it can
|
|
128
|
+
* carry them across. Default `true`.
|
|
129
|
+
*/
|
|
130
|
+
tagging?: boolean;
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
/** The marks, kept by the app — see {@link UseFolderViewOptions.store}. */
|
|
@@ -127,7 +137,7 @@ export interface FolderMarksStore {
|
|
|
127
137
|
write: (next: string | null) => void;
|
|
128
138
|
}
|
|
129
139
|
|
|
130
|
-
export function useFolderView({ key, store, device = 'local' }: UseFolderViewOptions): FolderView {
|
|
140
|
+
export function useFolderView({ key, store, device = 'local', tagging = true }: UseFolderViewOptions): FolderView {
|
|
131
141
|
// Hooks run unconditionally; an undefined key is the remembered preference that keeps nothing.
|
|
132
142
|
const [rememberedMarks, rememberMarks] = useRememberedPreference(store ? undefined : key);
|
|
133
143
|
const [rememberedLocal, rememberLocal] = useRememberedPreference(
|
|
@@ -186,6 +196,7 @@ export function useFolderView({ key, store, device = 'local' }: UseFolderViewOpt
|
|
|
186
196
|
local,
|
|
187
197
|
tags,
|
|
188
198
|
deviceHiding,
|
|
199
|
+
tagging,
|
|
189
200
|
hiddenIn: (id) => hiddenScope(marks, local, id),
|
|
190
201
|
hide: (ids, options) => {
|
|
191
202
|
if (options?.scope === 'device' && deviceHiding) saveLocal(setHidden(latestLocal.current, ids, true));
|
|
@@ -218,6 +229,6 @@ export function useFolderView({ key, store, device = 'local' }: UseFolderViewOpt
|
|
|
218
229
|
closePanel: () => setPanel(null),
|
|
219
230
|
onContextMenu,
|
|
220
231
|
}),
|
|
221
|
-
[marks, local, tags, deviceHiding, panel, saveMarks, saveLocal, onContextMenu],
|
|
232
|
+
[marks, local, tags, deviceHiding, tagging, panel, saveMarks, saveLocal, onContextMenu],
|
|
222
233
|
);
|
|
223
234
|
}
|
|
@@ -580,10 +580,14 @@ describe('FilterRail — resizeKey', () => {
|
|
|
580
580
|
expect(globalThis.localStorage?.getItem('test:rail')).toBeNull();
|
|
581
581
|
});
|
|
582
582
|
|
|
583
|
-
test('
|
|
584
|
-
globalThis.localStorage?.setItem('test:rail', '
|
|
585
|
-
const { container } = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
583
|
+
test('a stored sliver is never drawn — it opens at the snap, or shut below half of it', () => {
|
|
584
|
+
globalThis.localStorage?.setItem('test:rail', '120');
|
|
585
|
+
const { container, unmount } = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
586
586
|
expect((container.querySelector('nav') as HTMLElement).style.width).toBe('160px');
|
|
587
|
+
unmount();
|
|
588
|
+
globalThis.localStorage?.setItem('test:rail', '20');
|
|
589
|
+
const shut = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
590
|
+
expect((shut.container.querySelector('nav') as HTMLElement).style.width).toBe('0px');
|
|
587
591
|
});
|
|
588
592
|
|
|
589
593
|
test('no handle while collapsed', () => {
|
|
@@ -49,13 +49,35 @@ describe('useRailResize + RailResizeHandle', () => {
|
|
|
49
49
|
expect(aside.style.width).toBe('250px');
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// The owner, 2026-10-27: "let me drag them as far as I want including shutting a pane completely".
|
|
53
|
+
test('no wall: a drag reaches the whole row one way and shuts the rail the other', () => {
|
|
53
54
|
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
fireEvent.
|
|
55
|
+
const h = handle as HTMLElement;
|
|
56
|
+
// happy-dom lays nothing out, so the row is the window's width.
|
|
57
|
+
const row = globalThis.innerWidth;
|
|
58
|
+
fireEvent.pointerDown(h, { button: 0, clientX: 0, pointerId: 1 });
|
|
59
|
+
fireEvent.pointerMove(h, { clientX: 5000, pointerId: 1 });
|
|
60
|
+
expect(aside.style.width).toBe(`${row}px`);
|
|
61
|
+
expect(aside.style.maxWidth).toBe('100%');
|
|
62
|
+
// Between half the snap and the snap it holds — no sliver of a rail.
|
|
63
|
+
fireEvent.pointerMove(h, { clientX: 100, pointerId: 1 });
|
|
64
|
+
expect(aside.style.width).toBe('160px');
|
|
65
|
+
fireEvent.pointerMove(h, { clientX: 60, pointerId: 1 });
|
|
66
|
+
fireEvent.pointerUp(h, { clientX: 60, pointerId: 1 });
|
|
67
|
+
expect(aside.style.width).toBe('0px');
|
|
68
|
+
expect(aside.style.visibility).toBe('hidden');
|
|
69
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('0');
|
|
70
|
+
// Shut, the handle is still there to drag it back, and an arrow opens it at the snap.
|
|
71
|
+
expect(h.style.visibility).toBe('visible');
|
|
72
|
+
fireEvent.keyDown(h, { key: 'ArrowRight' });
|
|
58
73
|
expect(aside.style.width).toBe('160px');
|
|
74
|
+
expect(aside.style.visibility).toBe('');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a shut rail is remembered shut', () => {
|
|
78
|
+
globalThis.localStorage?.setItem('t:rail', '0');
|
|
79
|
+
const { aside } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
80
|
+
expect(aside.style.width).toBe('0px');
|
|
59
81
|
});
|
|
60
82
|
|
|
61
83
|
test('a right-button press is not a drag', () => {
|
|
@@ -13,15 +13,19 @@
|
|
|
13
13
|
* reach;
|
|
14
14
|
* - **a `separator` role with real `aria-value*`**, the ARIA splitter pattern;
|
|
15
15
|
* - **double-click resets** to the rail's own default width, and forgets the stored one;
|
|
16
|
-
* - remembered on RELEASE — a drag is one decision, not sixty writes a second
|
|
16
|
+
* - remembered on RELEASE — a drag is one decision, not sixty writes a second;
|
|
17
|
+
* - 🔴 **no wall** (the owner, 2026-10-27): the drag reaches the far side of the row the rail
|
|
18
|
+
* shares, and below half of `MIN_RAIL_PX` the rail SHUTS — `lib/paneReach.ts`, the rule every
|
|
19
|
+
* cursedbelt divider obeys. A shut rail draws nothing but its handle, at the edge, to drag back.
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
21
|
+
* Storage is `railWidth.ts`, so the width is announced through `preferenceSync` like every other
|
|
22
|
+
* viewer preference.
|
|
20
23
|
*/
|
|
21
24
|
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
|
22
25
|
import { useCallback, useRef, useState } from 'react';
|
|
23
26
|
import { cn } from '../lib/cn.js';
|
|
24
|
-
import {
|
|
27
|
+
import { reachSize, stepSize } from '../lib/paneReach.js';
|
|
28
|
+
import { MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
|
|
25
29
|
|
|
26
30
|
export interface UseRailResizeOptions {
|
|
27
31
|
/** Where the width is remembered. Absent: not resizable — no handle, no stored width. */
|
|
@@ -37,9 +41,15 @@ export interface RailResize<E extends HTMLElement = HTMLElement> {
|
|
|
37
41
|
railRef: RefObject<E | null>;
|
|
38
42
|
/** Whether a handle is drawn: a `resizeKey` was given and `enabled` is true. */
|
|
39
43
|
active: boolean;
|
|
40
|
-
/** The dragged width in px, or `null` for "the rail's own default width". Null while inactive. */
|
|
44
|
+
/** The dragged width in px (0 = shut), or `null` for "the rail's own default width". Null while inactive. */
|
|
41
45
|
width: number | null;
|
|
42
|
-
/**
|
|
46
|
+
/** Dragged all the way shut. The rail must then draw nothing but the handle — `style` does it. */
|
|
47
|
+
shut: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Spread onto the rail's `style` — `undefined` at the default width. 🔴 Spread ALL of it, not
|
|
50
|
+
* just `width`: a shut rail is also borderless and `visibility: hidden` (its content would
|
|
51
|
+
* otherwise spill out of a zero-wide box), and a wide one is held to its row by `maxWidth`.
|
|
52
|
+
*/
|
|
43
53
|
style: CSSProperties | undefined;
|
|
44
54
|
/** Forget the dragged width (what a double-click on the handle does). */
|
|
45
55
|
reset: () => void;
|
|
@@ -50,6 +60,8 @@ export interface RailResize<E extends HTMLElement = HTMLElement> {
|
|
|
50
60
|
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
|
|
51
61
|
/** The rail's current rendered width, for `aria-valuenow` before any drag. */
|
|
52
62
|
measure: () => number;
|
|
63
|
+
/** The width of the row the rail shares — how far it can grow. */
|
|
64
|
+
room: () => number;
|
|
53
65
|
};
|
|
54
66
|
}
|
|
55
67
|
|
|
@@ -62,6 +74,12 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
62
74
|
const [dragged, setDragged] = useState<number | null>(() => (resizeKey ? readRailWidth(resizeKey) : null));
|
|
63
75
|
const active = Boolean(resizeKey) && enabled;
|
|
64
76
|
const measure = useCallback(() => railRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX, []);
|
|
77
|
+
// The row the rail shares with the page it navigates. A box with no layout (a test DOM, a hidden
|
|
78
|
+
// tab) measures 0, so the window stands in — never 0, which would make every width "shut".
|
|
79
|
+
const room = useCallback(() => {
|
|
80
|
+
const row = railRef.current?.parentElement?.getBoundingClientRect().width ?? 0;
|
|
81
|
+
return row > 0 ? row : globalThis.innerWidth || MIN_RAIL_PX * 4;
|
|
82
|
+
}, []);
|
|
65
83
|
|
|
66
84
|
const onPointerDown = useCallback(
|
|
67
85
|
(event: ReactPointerEvent<HTMLElement>) => {
|
|
@@ -69,10 +87,11 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
69
87
|
const handle = event.currentTarget;
|
|
70
88
|
const startX = event.clientX;
|
|
71
89
|
const startWidth = measure();
|
|
90
|
+
const total = room();
|
|
72
91
|
let latest = startWidth;
|
|
73
92
|
handle.setPointerCapture?.(event.pointerId);
|
|
74
93
|
const move = (moveEvent: PointerEvent) => {
|
|
75
|
-
latest =
|
|
94
|
+
latest = Math.round(reachSize(startWidth + moveEvent.clientX - startX, MIN_RAIL_PX, total));
|
|
76
95
|
setDragged(latest);
|
|
77
96
|
};
|
|
78
97
|
const end = () => {
|
|
@@ -87,7 +106,7 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
87
106
|
handle.addEventListener('pointercancel', end);
|
|
88
107
|
event.preventDefault();
|
|
89
108
|
},
|
|
90
|
-
[resizeKey, measure],
|
|
109
|
+
[resizeKey, measure, room],
|
|
91
110
|
);
|
|
92
111
|
|
|
93
112
|
const onKeyDown = useCallback(
|
|
@@ -97,11 +116,11 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
97
116
|
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
98
117
|
if (!delta) return;
|
|
99
118
|
event.preventDefault();
|
|
100
|
-
const next =
|
|
119
|
+
const next = Math.round(stepSize(dragged ?? measure(), delta, MIN_RAIL_PX, room()));
|
|
101
120
|
setDragged(next);
|
|
102
121
|
writeRailWidth(resizeKey, next);
|
|
103
122
|
},
|
|
104
|
-
[resizeKey, dragged, measure],
|
|
123
|
+
[resizeKey, dragged, measure, room],
|
|
105
124
|
);
|
|
106
125
|
|
|
107
126
|
const reset = useCallback(() => {
|
|
@@ -110,16 +129,27 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
110
129
|
}, [resizeKey]);
|
|
111
130
|
|
|
112
131
|
const width = active ? dragged : null;
|
|
132
|
+
const shut = width === 0;
|
|
113
133
|
return {
|
|
114
134
|
railRef,
|
|
115
135
|
active,
|
|
116
136
|
width,
|
|
117
|
-
|
|
137
|
+
shut,
|
|
138
|
+
style: width === null ? undefined : shut ? SHUT_STYLE : { width, maxWidth: '100%' },
|
|
118
139
|
reset,
|
|
119
|
-
handle: { label, onPointerDown, onKeyDown, measure },
|
|
140
|
+
handle: { label, onPointerDown, onKeyDown, measure, room },
|
|
120
141
|
};
|
|
121
142
|
}
|
|
122
143
|
|
|
144
|
+
/** A shut rail: no width, no padding (a padded box is never narrower than its padding), no border,
|
|
145
|
+
* and nothing drawn but the handle, which opts back in. `overflow: visible`, because a rail that
|
|
146
|
+
* scrolls itself (`overflow-y-auto`) would clip its handle out of a zero-wide box — still in the
|
|
147
|
+
* DOM, never under the pointer; its content is hidden by `visibility` instead. */
|
|
148
|
+
const SHUT_STYLE: CSSProperties = { width: 0, minWidth: 0, padding: 0, borderRightWidth: 0, overflow: 'visible', visibility: 'hidden' };
|
|
149
|
+
/** The shut rail's handle: visible again, and wholly on the page side of the edge, so all 12px of
|
|
150
|
+
* it can be grabbed even when the rail sits against the window's edge. */
|
|
151
|
+
const SHUT_HANDLE_STYLE: CSSProperties = { visibility: 'visible', right: -12 };
|
|
152
|
+
|
|
123
153
|
/**
|
|
124
154
|
* The handle itself — a 12px hotspot centered on the rail's right border. The RAIL must be
|
|
125
155
|
* `position: relative` (the handle is absolutely positioned against it). Renders nothing while
|
|
@@ -130,7 +160,7 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
130
160
|
*/
|
|
131
161
|
export function RailResizeHandle({ rail, className, ...rest }: { rail: RailResize; className?: string }) {
|
|
132
162
|
if (!rail.active) return null;
|
|
133
|
-
const { label, onPointerDown, onKeyDown, measure } = rail.handle;
|
|
163
|
+
const { label, onPointerDown, onKeyDown, measure, room } = rail.handle;
|
|
134
164
|
return (
|
|
135
165
|
// biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
|
|
136
166
|
<div
|
|
@@ -138,9 +168,10 @@ export function RailResizeHandle({ rail, className, ...rest }: { rail: RailResiz
|
|
|
138
168
|
role='separator'
|
|
139
169
|
aria-orientation='vertical'
|
|
140
170
|
aria-label={`Resize ${label}`}
|
|
141
|
-
aria-valuemin={
|
|
142
|
-
aria-valuemax={
|
|
171
|
+
aria-valuemin={0}
|
|
172
|
+
aria-valuemax={Math.round(room())}
|
|
143
173
|
aria-valuenow={Math.round(rail.width ?? measure())}
|
|
174
|
+
{...(rail.shut ? { style: SHUT_HANDLE_STYLE } : {})}
|
|
144
175
|
tabIndex={0}
|
|
145
176
|
data-cb-rail-resize=''
|
|
146
177
|
onPointerDown={onPointerDown}
|