@joewinke/jatui 0.1.10 → 0.1.19
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/README.md +123 -0
- package/package.json +3 -1
- package/src/lib/actions/railNav.ts +473 -0
- package/src/lib/components/AnnotationLayer.svelte +108 -0
- package/src/lib/components/AnnotationPanel.svelte +319 -0
- package/src/lib/components/AudioWaveform.svelte +9 -5
- package/src/lib/components/AvailabilityModal.svelte +7 -3
- package/src/lib/components/AvatarUpload.svelte +27 -4
- package/src/lib/components/BookingForm.svelte +11 -9
- package/src/lib/components/BurndownChart.svelte +778 -0
- package/src/lib/components/Button.svelte +10 -1
- package/src/lib/components/CalendarPicker.svelte +3 -3
- package/src/lib/components/Card.svelte +2 -2
- package/src/lib/components/ChipInput.svelte +21 -15
- package/src/lib/components/ColorSelector.svelte +17 -13
- package/src/lib/components/CommentThread.svelte +773 -0
- package/src/lib/components/ConfirmDialog.svelte +348 -0
- package/src/lib/components/ConfirmModal.svelte +78 -11
- package/src/lib/components/ContextMenu.svelte +188 -0
- package/src/lib/components/CountdownTimer.svelte +1 -1
- package/src/lib/components/DateRangePicker.svelte +6 -4
- package/src/lib/components/Drawer.svelte +36 -3
- package/src/lib/components/EntityPreviewCard.svelte +104 -0
- package/src/lib/components/FileDropzone.svelte +493 -0
- package/src/lib/components/FilePicker.svelte +83 -14
- package/src/lib/components/FileThumbnail.svelte +80 -0
- package/src/lib/components/FilterDropdown.svelte +11 -11
- package/src/lib/components/HunkDiffView.svelte +348 -0
- package/src/lib/components/ImageLightbox.svelte +274 -0
- package/src/lib/components/ImageUpload.svelte +58 -9
- package/src/lib/components/InlineEdit.svelte +15 -9
- package/src/lib/components/InputDialog.svelte +327 -0
- package/src/lib/components/LazyImage.svelte +1 -0
- package/src/lib/components/LinkShortener.svelte +1 -1
- package/src/lib/components/LoadingSpinner.svelte +6 -2
- package/src/lib/components/MarkupEditor.svelte +485 -0
- package/src/lib/components/MarkupOverlay.svelte +55 -0
- package/src/lib/components/MediaWorkbench.svelte +871 -0
- package/src/lib/components/MilestoneCard.svelte +1 -1
- package/src/lib/components/MilestoneTimeline.svelte +1 -1
- package/src/lib/components/Modal.svelte +39 -4
- package/src/lib/components/PDFViewer.svelte +105 -0
- package/src/lib/components/PdfThumbnail.svelte +3 -1
- package/src/lib/components/PhoneInput.svelte +183 -63
- package/src/lib/components/ResizablePanel.svelte +4 -4
- package/src/lib/components/SearchDropdown.svelte +26 -13
- package/src/lib/components/SelectInput.svelte +26 -4
- package/src/lib/components/SidebarUserFooter.svelte +1 -1
- package/src/lib/components/SignaturePad.svelte +8 -4
- package/src/lib/components/SmartImageEditor.svelte +720 -0
- package/src/lib/components/SortDropdown.svelte +9 -3
- package/src/lib/components/Sparkline.svelte +9 -0
- package/src/lib/components/StatusBadge.svelte +20 -18
- package/src/lib/components/TextArea.svelte +24 -5
- package/src/lib/components/TextInput.svelte +29 -6
- package/src/lib/components/ThemeSelector.svelte +15 -4
- package/src/lib/components/TimeSlotPicker.svelte +7 -7
- package/src/lib/components/UserAvatar.svelte +14 -1
- package/src/lib/components/VariablePicker.svelte +170 -0
- package/src/lib/components/VoicePlayer.svelte +4 -3
- package/src/lib/components/markup.ts +287 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
- package/src/lib/components/messaging/ChannelList.svelte +1 -1
- package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
- package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
- package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
- package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
- package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
- package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
- package/src/lib/components/messaging/MessageInput.svelte +1 -1
- package/src/lib/components/messaging/MessageItem.svelte +6 -3
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
- package/src/lib/components/messaging/StartDMModal.svelte +1 -1
- package/src/lib/components/pipeline/Pipeline.svelte +4 -4
- package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
- package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
- package/src/lib/index.ts +105 -1
- package/src/lib/stores/confirmDialog.svelte.ts +48 -0
- package/src/lib/stores/inputDialog.svelte.ts +51 -0
- package/src/lib/styles/rail.css +63 -0
- package/src/lib/types/annotation.ts +38 -0
- package/src/lib/types/comments.ts +97 -0
- package/src/lib/types/entityPreview.ts +45 -0
- package/src/lib/types/filePicker.ts +2 -0
- package/src/lib/types/smartImageEditor.ts +39 -0
- package/src/lib/types/templateVars.ts +36 -0
- package/src/lib/utils/dateFormatters.ts +12 -10
- package/src/lib/utils/phone.ts +80 -0
- package/src/lib/utils/taskUtils.ts +21 -7
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @joewinke/jatui
|
|
2
|
+
|
|
3
|
+
Shared Svelte 5 component library for JAT projects.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @joewinke/jatui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Svelte 5 and DaisyUI 5 as peer dependencies.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Dialog Utilities
|
|
16
|
+
|
|
17
|
+
`ConfirmDialog` and `InputDialog` are global, promise-based dialogs that replace the browser's native `confirm()` / `prompt()`. They're keyboard-accessible, animated, and theme-aware.
|
|
18
|
+
|
|
19
|
+
### Setup — wire into root layout
|
|
20
|
+
|
|
21
|
+
Mount both components once at the app root so they're always available:
|
|
22
|
+
|
|
23
|
+
```svelte
|
|
24
|
+
<!-- src/routes/+layout.svelte -->
|
|
25
|
+
<script>
|
|
26
|
+
import { ConfirmDialog, InputDialog } from '@joewinke/jatui';
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<slot />
|
|
30
|
+
|
|
31
|
+
<ConfirmDialog />
|
|
32
|
+
<InputDialog />
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
### `showConfirm(opts)` → `Promise<boolean>`
|
|
38
|
+
|
|
39
|
+
Shows a modal confirmation dialog. Resolves `true` if the user confirms, `false` if they cancel or the timeout expires.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { showConfirm } from '@joewinke/jatui';
|
|
43
|
+
|
|
44
|
+
const ok = await showConfirm({
|
|
45
|
+
title: 'Delete project?',
|
|
46
|
+
body: 'This cannot be undone.',
|
|
47
|
+
danger: true,
|
|
48
|
+
timeoutMs: 10000, // auto-cancel after 10 s; progress bar drains to zero
|
|
49
|
+
confirmLabel: 'Delete', // default: 'Confirm' (danger) / 'OK' (non-danger)
|
|
50
|
+
cancelLabel: 'Keep', // default: 'Cancel'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (ok) {
|
|
54
|
+
await deleteProject(id);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Options (`ConfirmOptions`):**
|
|
59
|
+
|
|
60
|
+
| Option | Type | Default | Description |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `title` | `string` | required | Dialog heading |
|
|
63
|
+
| `body` | `string` | — | Supporting detail text |
|
|
64
|
+
| `danger` | `boolean` | `true` | Red styling + terminal icon when `true`; info styling otherwise |
|
|
65
|
+
| `timeoutMs` | `number` | `8000` | Auto-cancel after this many ms; `0` disables the timer |
|
|
66
|
+
| `confirmLabel` | `string` | `'Confirm'` / `'OK'` | Confirm button label |
|
|
67
|
+
| `cancelLabel` | `string` | `'Cancel'` | Cancel button label |
|
|
68
|
+
|
|
69
|
+
**Keyboard:** `Enter` confirms · `Escape` cancels · focus starts on Cancel (safe default for destructive actions).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### `showInput(opts)` → `Promise<string | null>`
|
|
74
|
+
|
|
75
|
+
Shows a modal text-input dialog. Resolves with the typed string on confirm, `null` on cancel.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { showInput } from '@joewinke/jatui';
|
|
79
|
+
|
|
80
|
+
const name = await showInput({
|
|
81
|
+
title: 'Rename project',
|
|
82
|
+
body: 'Enter a new name for this project.',
|
|
83
|
+
placeholder: 'My project',
|
|
84
|
+
maxLength: 60,
|
|
85
|
+
validate: (v) => v.trim().length < 2 ? 'Name must be at least 2 characters.' : null,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (name !== null) {
|
|
89
|
+
await renameProject(id, name.trim());
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Options (`InputOptions`):**
|
|
94
|
+
|
|
95
|
+
| Option | Type | Default | Description |
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
| `title` | `string` | required | Dialog heading |
|
|
98
|
+
| `body` | `string` | — | Supporting detail text |
|
|
99
|
+
| `placeholder` | `string` | — | Input placeholder text |
|
|
100
|
+
| `maxLength` | `number` | — | `maxlength` cap on the input field |
|
|
101
|
+
| `validate` | `(value: string) => string \| null` | — | Return an error message to block submission, `null` to allow it |
|
|
102
|
+
| `confirmLabel` | `string` | `'OK'` | Confirm button label |
|
|
103
|
+
| `cancelLabel` | `string` | `'Cancel'` | Cancel button label |
|
|
104
|
+
|
|
105
|
+
**Keyboard:** `Enter` submits (runs `validate` first) · `Escape` cancels · input is focused on open.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### Migration guide — replacing native `confirm()` / `prompt()`
|
|
110
|
+
|
|
111
|
+
```diff
|
|
112
|
+
- if (!confirm('Are you sure you want to delete this?')) return;
|
|
113
|
+
+ if (!await showConfirm({ title: 'Delete this?', danger: true })) return;
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```diff
|
|
117
|
+
- const name = prompt('Enter a new name:', currentName);
|
|
118
|
+
- if (name === null) return;
|
|
119
|
+
+ const name = await showInput({ title: 'Rename', placeholder: currentName });
|
|
120
|
+
+ if (name === null) return;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Both dialogs are non-blocking (`await`), keyboard-navigable, and respect the app's DaisyUI theme. Unlike native dialogs they don't pause the JavaScript event loop.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joewinke/jatui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Shared Svelte 5 component library for JAT projects",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
"vite": "^6.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"diff": "^9.0.0",
|
|
48
|
+
"libphonenumber-js": "^1.12.41",
|
|
47
49
|
"pdfjs-dist": "^5.6.205",
|
|
48
50
|
"svelte-dnd-action": "^0.9.69"
|
|
49
51
|
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard-rail navigation primitive — action + composable.
|
|
3
|
+
*
|
|
4
|
+
* A drawer / form is a vertical sequence of "rungs". Focus starts in the first
|
|
5
|
+
* field. Up / Down (and j / k when NOT typing) step between rungs. Left / Right
|
|
6
|
+
* acts on the focused rung (cycle a value, switch a tab, open a picker). Escape
|
|
7
|
+
* pops focus out of a field back to the container, then fires `onEscape`.
|
|
8
|
+
*
|
|
9
|
+
* Rungs are ordinary HTML elements tagged with a `data-rail-id` attribute (or
|
|
10
|
+
* whatever attribute `attr` specifies). The consumer supplies an `order` array
|
|
11
|
+
* of ids that defines traversal; ids whose element is not currently rendered
|
|
12
|
+
* in the container are skipped at move time.
|
|
13
|
+
*
|
|
14
|
+
* ─── CSS contract ─────────────────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* The controller applies two classes to rung host elements. Style them in your
|
|
17
|
+
* stylesheet (or in a parent scope):
|
|
18
|
+
*
|
|
19
|
+
* .rail-focus — persistent: applied to the currently active rung host. Use
|
|
20
|
+
* `:focus-visible` / `:has(:focus-visible)` inside the rule so
|
|
21
|
+
* only keyboard-driven focus shows a visible ring.
|
|
22
|
+
*
|
|
23
|
+
* .rail-cycled — transient (~280ms): applied when Left / Right changes a
|
|
24
|
+
* rung's value via `rail.bump(host)`. Use for a brief pulse
|
|
25
|
+
* that confirms the value changed.
|
|
26
|
+
*
|
|
27
|
+
* ─── Usage — action form (simplest) ──────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* <div use:railNav={{
|
|
30
|
+
* order: () => ['title', 'type', 'priority'],
|
|
31
|
+
* onAction: (id, dir, host) => cycleField(id, dir),
|
|
32
|
+
* onEscape: () => closeDrawer(),
|
|
33
|
+
* }}>
|
|
34
|
+
* <input data-rail-id="title" />
|
|
35
|
+
* <div data-rail-id="type" tabindex="-1">…</div>
|
|
36
|
+
* <div data-rail-id="priority" tabindex="-1">…</div>
|
|
37
|
+
* </div>
|
|
38
|
+
*
|
|
39
|
+
* ─── Usage — composable form ──────────────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* const rail = createRailNav({
|
|
42
|
+
* container: panelEl,
|
|
43
|
+
* order: () => currentOrder,
|
|
44
|
+
* onAction: (id, dir, host) => { rail.bump(host); cycleField(id, dir); },
|
|
45
|
+
* onEscape: () => closeDrawer(),
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* // Wire both listeners on the container:
|
|
49
|
+
* panelEl.addEventListener('keydown', (e) => rail.handleKeydown(e));
|
|
50
|
+
* panelEl.addEventListener('focusin', (e) => rail.handleFocusin(e));
|
|
51
|
+
*
|
|
52
|
+
* ─── How to wire a drawer to railNav ──────────────────────────────────────────
|
|
53
|
+
*
|
|
54
|
+
* 1. Tag each rung host with `data-rail-id`:
|
|
55
|
+
* <div data-rail-id="type" tabindex="-1">…</div>
|
|
56
|
+
*
|
|
57
|
+
* 2. Apply the action (or composable) to the drawer panel:
|
|
58
|
+
* <div bind:this={panel} use:railNav={{ order, onAction, onEscape }} …>
|
|
59
|
+
*
|
|
60
|
+
* 3. Handle Left/Right in `onAction` and call `rail.bump(host)` on change:
|
|
61
|
+
* onAction: (id, dir, host) => { formData[id] = cycle(opts[id], …, dir); rail.bump(host); }
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import type { Action } from 'svelte/action';
|
|
65
|
+
|
|
66
|
+
// ─── Public types ──────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export interface RailNavOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Rung id order, top → bottom. May be a plain array or a getter (called on
|
|
71
|
+
* every navigation event so reactive lists stay in sync).
|
|
72
|
+
*/
|
|
73
|
+
order: string[] | (() => string[]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Called when Left / Right is pressed on a non-text rung.
|
|
77
|
+
* `dir` is `1` for Right and `-1` for Left.
|
|
78
|
+
* Call `rail.bump(host)` inside this handler if you want the pulse effect.
|
|
79
|
+
*/
|
|
80
|
+
onAction?: (rungId: string, dir: 1 | -1, host: HTMLElement) => void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Called when Escape is pressed while focus is already on (or has been
|
|
84
|
+
* popped back to) the container itself. Typically closes the drawer / modal.
|
|
85
|
+
*/
|
|
86
|
+
onEscape?: () => void;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Optional: when a child popover currently owns keyboard focus, return `true`
|
|
90
|
+
* here to suppress rail navigation. Escape will call `onPopoverEscape` first.
|
|
91
|
+
*/
|
|
92
|
+
isPopoverOpen?: () => boolean;
|
|
93
|
+
|
|
94
|
+
/** Called on Escape when `isPopoverOpen()` returns true. */
|
|
95
|
+
onPopoverEscape?: () => void;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* When `true` (default), keystrokes on non-text rungs are stopped from
|
|
99
|
+
* bubbling to window-level listeners behind a modal.
|
|
100
|
+
*/
|
|
101
|
+
trap?: boolean;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Attribute used as the rung identifier. Default: `'data-rail-id'`.
|
|
105
|
+
* The controller looks for elements matching `[<attr>="<id>"]`.
|
|
106
|
+
*/
|
|
107
|
+
attr?: string;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The container element to search for rung hosts.
|
|
111
|
+
*
|
|
112
|
+
* Required for `createRailNav`. The `railNav` action fills this automatically
|
|
113
|
+
* from the action's `node`.
|
|
114
|
+
*/
|
|
115
|
+
container?: HTMLElement;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface RailNavController {
|
|
119
|
+
/**
|
|
120
|
+
* Feed a keydown event. Returns `true` if the event was consumed.
|
|
121
|
+
* Attach to the container's `keydown` listener (or `onkeydown` in Svelte).
|
|
122
|
+
*/
|
|
123
|
+
handleKeydown(event: KeyboardEvent): boolean;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Feed a focusin event so the controller can track which rung the user
|
|
127
|
+
* Tabbed into. Attach to the container's `focusin` listener.
|
|
128
|
+
*/
|
|
129
|
+
handleFocusin(event: FocusEvent): void;
|
|
130
|
+
|
|
131
|
+
/** Step one rung in direction `1` (down) or `-1` (up). Wraps around. */
|
|
132
|
+
moveRung(dir: 1 | -1): void;
|
|
133
|
+
|
|
134
|
+
/** Focus the rung with the given id. No-op if the element is not rendered. */
|
|
135
|
+
focus(id: string): void;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Re-derive the current rung list after a conditional rung appears or
|
|
139
|
+
* disappears. The action calls this automatically in its `update` hook.
|
|
140
|
+
* Call manually in the composable form after reactive state changes.
|
|
141
|
+
*/
|
|
142
|
+
refresh(): void;
|
|
143
|
+
|
|
144
|
+
/** The id of the last rung that received focus, or `''` if none. */
|
|
145
|
+
currentId(): string;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Applies the transient `rail-cycled` class to `host` for ~280ms.
|
|
149
|
+
* Call this inside `onAction` when a Left / Right key changes a value.
|
|
150
|
+
*/
|
|
151
|
+
bump(host: HTMLElement | null): void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Exported helpers ──────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Cycle through `list`, returning the item `dir` steps from `current`.
|
|
158
|
+
* Wraps around. Returns `current` unchanged when the list is empty.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* cycle(['bug', 'feature', 'task'], 'feature', 1) // → 'task'
|
|
162
|
+
* cycle(['bug', 'feature', 'task'], 'bug', -1) // → 'task'
|
|
163
|
+
* cycle(['a', 'b'], 'z', 1) // → 'a' (not found → start)
|
|
164
|
+
*/
|
|
165
|
+
export function cycle<T>(list: readonly T[], current: T, dir: 1 | -1): T {
|
|
166
|
+
if (list.length === 0) return current;
|
|
167
|
+
const i = list.indexOf(current);
|
|
168
|
+
// When `current` is not in the list, treat it as if we're at position -1
|
|
169
|
+
// so that `dir=1` lands at index 0 (the first item) and `dir=-1` lands at
|
|
170
|
+
// the last item — matching the "not found → start from the beginning" idiom.
|
|
171
|
+
const base = i === -1 ? -1 : i;
|
|
172
|
+
return list[(((base + dir) % list.length) + list.length) % list.length];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Internals ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
const BUMP_DURATION_MS = 280;
|
|
178
|
+
const RAIL_FOCUS_CLASS = 'rail-focus';
|
|
179
|
+
const RAIL_CYCLED_CLASS = 'rail-cycled';
|
|
180
|
+
|
|
181
|
+
interface OptsRef {
|
|
182
|
+
current: RailNavOptions;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resolveOrder(opts: RailNavOptions): string[] {
|
|
186
|
+
return typeof opts.order === 'function' ? opts.order() : opts.order;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function resolveAttr(opts: RailNavOptions): string {
|
|
190
|
+
return opts.attr ?? 'data-rail-id';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Return all currently-rendered rung hosts in the rail order. */
|
|
194
|
+
function resolveHosts(optsRef: OptsRef): HTMLElement[] {
|
|
195
|
+
const { container } = optsRef.current;
|
|
196
|
+
if (!container) return [];
|
|
197
|
+
const attr = resolveAttr(optsRef.current);
|
|
198
|
+
const order = resolveOrder(optsRef.current);
|
|
199
|
+
return order
|
|
200
|
+
.map((id) => container.querySelector<HTMLElement>(`[${attr}="${id}"]`))
|
|
201
|
+
.filter((el): el is HTMLElement => !!el);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Focus the most appropriate control inside a rung host:
|
|
206
|
+
*
|
|
207
|
+
* 1. If the host itself is directly focusable (input / select / textarea /
|
|
208
|
+
* button / [contenteditable] / [tabindex]), focus it.
|
|
209
|
+
* 2. Otherwise search for a real field before a trigger button. We use an
|
|
210
|
+
* explicit selector for real fields first because `querySelector` resolves
|
|
211
|
+
* by document order — a label's `<button>` can appear before the field it
|
|
212
|
+
* accompanies (e.g. a voice-mic button before a contenteditable).
|
|
213
|
+
* 3. Fall back to any `.sd-trigger,button` (SearchDropdown trigger etc.).
|
|
214
|
+
* 4. Last resort: focus the host itself (requires tabindex on the host).
|
|
215
|
+
*/
|
|
216
|
+
function focusRailHost(host: HTMLElement): void {
|
|
217
|
+
if (
|
|
218
|
+
host.matches('input,select,textarea,button,[contenteditable="true"],[tabindex]')
|
|
219
|
+
) {
|
|
220
|
+
host.focus();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const inner =
|
|
224
|
+
host.querySelector<HTMLElement>('[contenteditable="true"],input,select,textarea') ??
|
|
225
|
+
host.querySelector<HTMLElement>('.sd-trigger,button');
|
|
226
|
+
(inner ?? host).focus();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function makeController(optsRef: OptsRef): RailNavController {
|
|
230
|
+
let _currentRailId = '';
|
|
231
|
+
let _lastFocusedHost: HTMLElement | null = null;
|
|
232
|
+
|
|
233
|
+
function applyFocusClass(host: HTMLElement | null): void {
|
|
234
|
+
if (_lastFocusedHost && _lastFocusedHost !== host) {
|
|
235
|
+
_lastFocusedHost.classList.remove(RAIL_FOCUS_CLASS);
|
|
236
|
+
}
|
|
237
|
+
if (host) host.classList.add(RAIL_FOCUS_CLASS);
|
|
238
|
+
_lastFocusedHost = host;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function bump(host: HTMLElement | null): void {
|
|
242
|
+
if (!host) return;
|
|
243
|
+
host.classList.remove(RAIL_CYCLED_CLASS);
|
|
244
|
+
void host.offsetWidth; // reflow so the animation restarts on rapid presses
|
|
245
|
+
host.classList.add(RAIL_CYCLED_CLASS);
|
|
246
|
+
setTimeout(() => host.classList.remove(RAIL_CYCLED_CLASS), BUMP_DURATION_MS);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function focus(id: string): void {
|
|
250
|
+
const { container } = optsRef.current;
|
|
251
|
+
if (!container) return;
|
|
252
|
+
const attr = resolveAttr(optsRef.current);
|
|
253
|
+
const host = container.querySelector<HTMLElement>(`[${attr}="${id}"]`);
|
|
254
|
+
if (host) {
|
|
255
|
+
focusRailHost(host);
|
|
256
|
+
applyFocusClass(host);
|
|
257
|
+
_currentRailId = id;
|
|
258
|
+
} else {
|
|
259
|
+
container.focus();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function moveRung(dir: 1 | -1): void {
|
|
264
|
+
const hosts = resolveHosts(optsRef);
|
|
265
|
+
if (hosts.length === 0) return;
|
|
266
|
+
const attr = resolveAttr(optsRef.current);
|
|
267
|
+
const ids = hosts.map((h) => h.getAttribute(attr)!);
|
|
268
|
+
|
|
269
|
+
// Anchor on the actually-focused rung first. Fall back to the last tracked
|
|
270
|
+
// rung (covers focus parked on the container after an Escape pop-out).
|
|
271
|
+
const active = document.activeElement as HTMLElement | null;
|
|
272
|
+
const activeFocused = (
|
|
273
|
+
active?.closest<HTMLElement>(`[${attr}]`)
|
|
274
|
+
)?.getAttribute(attr);
|
|
275
|
+
const fromId =
|
|
276
|
+
activeFocused && ids.includes(activeFocused) ? activeFocused : _currentRailId;
|
|
277
|
+
|
|
278
|
+
let pos = fromId ? ids.indexOf(fromId) : -1;
|
|
279
|
+
if (pos === -1) pos = dir === 1 ? -1 : hosts.length;
|
|
280
|
+
const next = (((pos + dir) % hosts.length) + hosts.length) % hosts.length;
|
|
281
|
+
focusRailHost(hosts[next]);
|
|
282
|
+
applyFocusClass(hosts[next]);
|
|
283
|
+
_currentRailId = ids[next];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function refresh(): void {
|
|
287
|
+
// Re-derive hosts; re-apply focus class if the current rung is still rendered
|
|
288
|
+
// (DOM may have been reconstructed by Svelte's reactive updates).
|
|
289
|
+
const hosts = resolveHosts(optsRef);
|
|
290
|
+
const attr = resolveAttr(optsRef.current);
|
|
291
|
+
for (const h of hosts) {
|
|
292
|
+
if (h.getAttribute(attr) === _currentRailId) {
|
|
293
|
+
applyFocusClass(h);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function handleFocusin(event: FocusEvent): void {
|
|
300
|
+
const attr = resolveAttr(optsRef.current);
|
|
301
|
+
const order = resolveOrder(optsRef.current);
|
|
302
|
+
const host = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
|
303
|
+
`[${attr}]`
|
|
304
|
+
);
|
|
305
|
+
const id = host?.getAttribute(attr);
|
|
306
|
+
// Only track rungs that are in the rail order. Tabbing into an off-rail
|
|
307
|
+
// control (e.g. a description textarea) leaves the last real rung intact
|
|
308
|
+
// so Up / Down can resume from it after the user tabs back out.
|
|
309
|
+
if (id && order.includes(id)) {
|
|
310
|
+
_currentRailId = id;
|
|
311
|
+
applyFocusClass(host!);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function handleKeydown(event: KeyboardEvent): boolean {
|
|
316
|
+
const opts = optsRef.current;
|
|
317
|
+
const el = document.activeElement as HTMLElement | null;
|
|
318
|
+
const tag = el?.tagName;
|
|
319
|
+
const isTextField =
|
|
320
|
+
!!el &&
|
|
321
|
+
(tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true);
|
|
322
|
+
const isMultiline =
|
|
323
|
+
!!el && (tag === 'TEXTAREA' || el.isContentEditable === true);
|
|
324
|
+
const hasMod = event.metaKey || event.ctrlKey || event.altKey;
|
|
325
|
+
|
|
326
|
+
// Modal trap — stop non-text keystrokes from bubbling to window-level page
|
|
327
|
+
// listeners (e.g., a listNav attached to `window`). Text-field keystrokes
|
|
328
|
+
// propagate normally so typing in the drawer doesn't interfere.
|
|
329
|
+
const trap = opts.trap !== false;
|
|
330
|
+
if (trap && !isTextField) event.stopPropagation();
|
|
331
|
+
|
|
332
|
+
// Escape cascade:
|
|
333
|
+
// 1. popover open → close popover (e.g. project dropdown)
|
|
334
|
+
// 2. focus inside a descendant control → pop back to container
|
|
335
|
+
// 3. focus already on the container (or just popped) → call onEscape
|
|
336
|
+
if (event.key === 'Escape' && !hasMod) {
|
|
337
|
+
const popoverOpen = opts.isPopoverOpen?.() ?? false;
|
|
338
|
+
if (popoverOpen) {
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
opts.onPopoverEscape?.();
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
const { container } = opts;
|
|
344
|
+
if (container && el && el !== container && container.contains(el)) {
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
container.focus();
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
opts.onEscape?.();
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Rail motion — suppressed when a popover is open or a modifier is held.
|
|
355
|
+
const popoverOpen = opts.isPopoverOpen?.() ?? false;
|
|
356
|
+
if (!hasMod && !popoverOpen) {
|
|
357
|
+
// Vertical: ArrowDown / j step down; ArrowUp / k step up.
|
|
358
|
+
// j / k are blocked in ANY text field; arrows are blocked only inside
|
|
359
|
+
// multiline fields so the caret can still move line-by-line.
|
|
360
|
+
const goDown = event.key === 'ArrowDown' || event.key === 'j';
|
|
361
|
+
const goUp = event.key === 'ArrowUp' || event.key === 'k';
|
|
362
|
+
if (goDown || goUp) {
|
|
363
|
+
const isVimKey = event.key === 'j' || event.key === 'k';
|
|
364
|
+
const blocked = isVimKey ? isTextField : isMultiline;
|
|
365
|
+
if (!blocked) {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
moveRung(goDown ? 1 : -1);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Horizontal: ArrowLeft / ArrowRight dispatch to `onAction`.
|
|
373
|
+
// Skipped inside text fields so caret-left / caret-right keep working.
|
|
374
|
+
if (
|
|
375
|
+
(event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
|
|
376
|
+
!isTextField
|
|
377
|
+
) {
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
const attr = resolveAttr(opts);
|
|
380
|
+
// Prefer the element that actually has DOM focus, then fall back to
|
|
381
|
+
// the last tracked rung (focus parked on the container after pop-out).
|
|
382
|
+
const focusedHost =
|
|
383
|
+
(el?.closest<HTMLElement>(`[${attr}]`)) ??
|
|
384
|
+
(opts.container && _currentRailId
|
|
385
|
+
? opts.container.querySelector<HTMLElement>(
|
|
386
|
+
`[${attr}="${_currentRailId}"]`
|
|
387
|
+
)
|
|
388
|
+
: null);
|
|
389
|
+
if (focusedHost && opts.onAction) {
|
|
390
|
+
const rungId = focusedHost.getAttribute(attr) ?? '';
|
|
391
|
+
opts.onAction(
|
|
392
|
+
rungId,
|
|
393
|
+
event.key === 'ArrowRight' ? 1 : -1,
|
|
394
|
+
focusedHost
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
handleKeydown,
|
|
406
|
+
handleFocusin,
|
|
407
|
+
moveRung,
|
|
408
|
+
focus,
|
|
409
|
+
refresh,
|
|
410
|
+
currentId: () => _currentRailId,
|
|
411
|
+
bump,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── Composable ────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Imperative composable — use when you already own keydown / focusin handlers
|
|
419
|
+
* and want `railNav` to decide whether to consume the event.
|
|
420
|
+
*
|
|
421
|
+
* You **must** supply `options.container` and wire both events:
|
|
422
|
+
*
|
|
423
|
+
* const rail = createRailNav({ container: panel, order, onAction, onEscape });
|
|
424
|
+
* panel.addEventListener('keydown', (e) => rail.handleKeydown(e));
|
|
425
|
+
* panel.addEventListener('focusin', (e) => rail.handleFocusin(e));
|
|
426
|
+
*/
|
|
427
|
+
export function createRailNav(options: RailNavOptions): RailNavController {
|
|
428
|
+
const ref: OptsRef = { current: options };
|
|
429
|
+
return makeController(ref);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── Action ────────────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Svelte action — attaches `keydown` and `focusin` listeners to `node` and
|
|
436
|
+
* handles rung stepping, horizontal action dispatch, and Escape cascade.
|
|
437
|
+
*
|
|
438
|
+
* @example
|
|
439
|
+
* <div use:railNav={{ order, onAction, onEscape }}>
|
|
440
|
+
* <input data-rail-id="title" />
|
|
441
|
+
* <div data-rail-id="type" tabindex="-1">…</div>
|
|
442
|
+
* <div data-rail-id="priority" tabindex="-1">…</div>
|
|
443
|
+
* </div>
|
|
444
|
+
*/
|
|
445
|
+
export const railNav: Action<HTMLElement, RailNavOptions | undefined> = (
|
|
446
|
+
node,
|
|
447
|
+
initial = { order: [] }
|
|
448
|
+
) => {
|
|
449
|
+
const ref: OptsRef = { current: { ...initial, container: node } };
|
|
450
|
+
const controller = makeController(ref);
|
|
451
|
+
|
|
452
|
+
function onKeydown(e: KeyboardEvent) {
|
|
453
|
+
controller.handleKeydown(e);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function onFocusin(e: FocusEvent) {
|
|
457
|
+
controller.handleFocusin(e);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
node.addEventListener('keydown', onKeydown);
|
|
461
|
+
node.addEventListener('focusin', onFocusin);
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
update(next: RailNavOptions | undefined = { order: [] }) {
|
|
465
|
+
ref.current = { ...next, container: node };
|
|
466
|
+
controller.refresh();
|
|
467
|
+
},
|
|
468
|
+
destroy() {
|
|
469
|
+
node.removeEventListener('keydown', onKeydown);
|
|
470
|
+
node.removeEventListener('focusin', onFocusin);
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
};
|