@greendrake/ui 0.6.0 → 0.8.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/README.md +53 -11
- package/package.json +5 -5
- package/src/BottomNav.vue +2 -2
- package/src/CircularProgress.vue +6 -2
- package/src/ContextMenu.vue +77 -0
- package/src/Copy.vue +2 -1
- package/src/FieldTip.ts +4 -4
- package/src/InfoBlock.vue +45 -0
- package/src/InfoTip.vue +33 -0
- package/src/MenuButton.vue +3 -1
- package/src/ModalOnClick.vue +13 -6
- package/src/RowSelect.ts +4 -27
- package/src/SplitPane.vue +85 -0
- package/src/SwitchRow.vue +74 -0
- package/src/TabBar.vue +11 -0
- package/src/TabPanels.vue +38 -0
- package/src/{FieldTip.scss → Tip.scss} +7 -7
- package/src/TreeView.vue +368 -69
- package/src/capturePointer.ts +15 -0
- package/src/main.ts +12 -3
- package/src/messages.ts +17 -7
- package/src/useSelection.ts +67 -72
- package/src/useWidthDrag.ts +38 -0
package/README.md
CHANGED
|
@@ -27,13 +27,14 @@ Read with no fallback, so the app defines them:
|
|
|
27
27
|
|
|
28
28
|
| Property | Read by |
|
|
29
29
|
| --- | --- |
|
|
30
|
-
| `--font-color` | text throughout: `AsyncSearch`, `BottomNav`, `Checkbox`, `Gallery`, `Modal`, `Password`, `SearchField`, `TreeView` |
|
|
30
|
+
| `--font-color` | text throughout: `AsyncSearch`, `BottomNav`, `Checkbox`, `ContextMenu`, `Gallery`, `Modal`, `Password`, `SearchField`, `SwitchRow`, `TreeView` |
|
|
31
31
|
| `--background-color` | `AsyncSearch` narrow panel |
|
|
32
|
-
| `--form-background`, `--form-background-hover` | control surfaces: `AsyncSearchResults`, `BottomNav`, `SegmentedControl`, `TabBar`, `ThumbnailGrid`, `TreeView` |
|
|
32
|
+
| `--form-background`, `--form-background-hover` | control surfaces: `AsyncSearchResults`, `BottomNav`, `ContextMenu`, `SegmentedControl`, `TabBar`, `ThumbnailGrid`, `TreeView` |
|
|
33
33
|
| `--input-background` | `Checkbox` box |
|
|
34
|
-
| `--border-color`, `--border-color-light`, `--border-color-hover` | control and container borders |
|
|
34
|
+
| `--border-color`, `--border-color-light`, `--border-color-hover` | control and container borders; `--border-color-light` is also the `SplitPane` handle |
|
|
35
35
|
| `--border-radius` | `AsyncSearch`, `AsyncSearchResults`, `Checkbox`, `ThumbnailGrid` (`FieldTip` falls back to 4px) |
|
|
36
|
-
| `--
|
|
36
|
+
| `--border-radius-pill` | `SwitchRow` track |
|
|
37
|
+
| `--accent-color` | active and checked states: `BottomNav`, `Checkbox`, `SegmentedControl`, `SwitchRow`, `TabBar`, toasts; the hovered `SplitPane` handle; `TreeView` drop targets |
|
|
37
38
|
| `--space-1` … `--space-5` | spacing |
|
|
38
39
|
| `--font-xs`, `--font-md`, `--font-lg` | `BottomNav` and `ThumbnailGrid` labels, `FieldTip`, `Modal` title |
|
|
39
40
|
| `--line-height-normal` | `Password` |
|
|
@@ -53,14 +54,14 @@ Read with a local default:
|
|
|
53
54
|
| `--bottomnav-height` | `BottomNav` bar height; bottom edge of the `AsyncSearch` narrow panel | none / 0px |
|
|
54
55
|
| `--safe-area-inset-top`, `--safe-area-inset-bottom` | toast layer; `Modal` sheet and `BottomNav` padding | 0px |
|
|
55
56
|
| `--max-content-width` | `TheWrap` width cap | none |
|
|
56
|
-
| `--tippy-background-color` | `FieldTip` validation tips | `#5335e8` |
|
|
57
|
+
| `--tippy-background-color` | `FieldTip` validation tips, `InfoTip` tooltips | `#5335e8` |
|
|
57
58
|
| `--toast-color`, `--toast-background` | toasts | `#fff`; `--accent-color` (`--warning-color` for the warning variant) |
|
|
58
59
|
| `--color-attention-fill`, `--color-attention-contrast` | `BottomNav` badge | `--accent-color` / `#fff` |
|
|
59
60
|
| `--vs-*` | `SelectBox` (see the component) | per property |
|
|
60
61
|
|
|
61
62
|
Breakpoint: "narrow" is `@greendrake/scss-kit`'s 600px boundary; `Modal`, `TabBar` and `AsyncSearch` switch presentation below it.
|
|
62
63
|
|
|
63
|
-
Tooltips: `FieldTip`, `MenuButton` and `AsyncSearch` use `vue-tippy`. Each ships the styles of
|
|
64
|
+
Tooltips: `FieldTip`, `InfoTip`, `MenuButton`, `ContextMenu` and `AsyncSearch` use `vue-tippy`. Each ships the styles of the tippy theme it uses (`tip` for the first two, `HMenu`, `ContextMenu`, `asyncSearchResults`); no global tippy.js stylesheet is loaded or required.
|
|
64
65
|
|
|
65
66
|
## Messages
|
|
66
67
|
|
|
@@ -74,6 +75,8 @@ configureUiMessages({ cancel: 'Abbrechen', nothingFound: 'Nichts gefunden' })
|
|
|
74
75
|
|
|
75
76
|
Keys and defaults: `loading` "Loading", `ok`, `cancel`, `close`, `clear`, `tryAgain`, `trying` "Trying...", `retryCountdown` "Error. Retrying in {seconds}" (`{seconds}` is substituted at render time), `toHomePage`, `nothingFound`, `searchResults`, `search`, `create`, `refresh`, `delete`, `deleting`, `newTab`, `copied`, `clickToCopy`, `invalidEmail` "Not a valid email address", `selectOption` "Select an option", `errorOther`, `errorNetwork`, `errorNotFound`.
|
|
76
77
|
|
|
78
|
+
`configurableMessages(defaults)` is the same arrangement for a package of its own: `{ messages, configure }`, `messages` reactive and starting as `defaults` (`@greendrake/ui-rich`'s toolbar strings are kept this way).
|
|
79
|
+
|
|
77
80
|
## Form controls
|
|
78
81
|
|
|
79
82
|
### TextField
|
|
@@ -123,7 +126,9 @@ Caption + control column. Props: `label`; `group` wraps the slot in a `role="gro
|
|
|
123
126
|
|
|
124
127
|
### FieldTip
|
|
125
128
|
|
|
126
|
-
`FieldTip(source: Ref<string | false>, target: string | Ref<HTMLElement | ComponentPublicInstance | null>): void` — binds a manually-triggered tippy tooltip (theme `
|
|
129
|
+
`FieldTip(source: Ref<string | false>, target: string | Ref<HTMLElement | ComponentPublicInstance | null>): void` — binds a manually-triggered tippy tooltip (theme `tip`) to an element or element id: setting `source` to a string shows it as text, `false` hides it; user dismissal resets `source` to `false`. Call it from `setup`; it cleans up on unmount.
|
|
130
|
+
|
|
131
|
+
`<InfoTip text="…" />` — a small "?" button whose `text` shows in a tooltip (theme `tip`) on hover, on focus, and on a tap where there is no hover; its accessible name is `messages.aboutThis`. Place it beside a field's `<label>`, not inside: within one it would become the control the label names.
|
|
127
132
|
|
|
128
133
|
### validationScopeKey
|
|
129
134
|
|
|
@@ -203,7 +208,7 @@ const save = async (): Promise<void> => {
|
|
|
203
208
|
|
|
204
209
|
### ModalOnClick
|
|
205
210
|
|
|
206
|
-
Trigger element plus a `Modal` around the `content` slot. The default slot is the trigger (`tag`, default `'a'`; `id`, `type` and other attributes land on it); `modalClass`
|
|
211
|
+
Trigger element plus a `Modal` around the `content` slot. The default slot is the trigger (`tag`, default `'a'`; `id`, `type`, `ariaLabel` and other attributes land on it); `modalClass` and `modalTestid` (its `data-testid`) go to the modal. Modal chrome props `title`, `okLabel`, `cancelLabel`, `okDisabled`, `hideOk`, `closable`, `expandable`, `buttons` are forwarded as given, unset ones keeping `Modal`'s defaults. Emits `click(event)` and `open` on trigger, `ok` (re-emitted without closing — an async submitter keeps the modal open on failure and closes via the exposed `onCancel()` on success), `cancel` (closes, then re-emits). Exposes `open(event)`, `onCancel()`.
|
|
207
212
|
|
|
208
213
|
### Gallery
|
|
209
214
|
|
|
@@ -213,7 +218,11 @@ Full-screen image viewer in an expanded `Modal`. Props: `items: GalleryItem[]` (
|
|
|
213
218
|
|
|
214
219
|
### MenuButton
|
|
215
220
|
|
|
216
|
-
Hover/click dropdown built on `vue-tippy`'s `Tippy` (theme `HMenu`, placement `bottom-end`); any click inside the menu closes it. Slots: `main` (trigger), `menuitems` (menu content, wrapped in `.Menubutton-Menu`). Props: `menuClass` (on the wrapper), `boxClass` (on the tippy box, applied once on create), `offset` (default `[0, -1]`), `matchTriggerWidth` (pins the popper width to the trigger's).
|
|
221
|
+
Hover/click dropdown built on `vue-tippy`'s `Tippy` (theme `HMenu`, placement `bottom-end`), appended to `body`, or to the element filling the screen while one does; any click inside the menu closes it. Slots: `main` (trigger), `menuitems` (menu content, wrapped in `.Menubutton-Menu`). Props: `menuClass` (on the wrapper), `boxClass` (on the tippy box, applied once on create), `offset` (default `[0, -1]`), `matchTriggerWidth` (pins the popper width to the trigger's).
|
|
222
|
+
|
|
223
|
+
### ContextMenu
|
|
224
|
+
|
|
225
|
+
A menu at the pointer in place of the browser's own, built on `vue-tippy`'s `Tippy` (theme `ContextMenu`), appended to `body`. Prop `items: ContextMenuItem[]` (`{ label, run: () => unknown }`), rendered as `role="menuitem"` buttons. Exposes `open(event: MouseEvent)`: call it from a `contextmenu` listener — it cancels the browser's menu and shows this one at the event's point. Choosing an item closes the menu and runs the item; a press outside the menu, or any key, closes it. Place it inside the element it serves: a press there closes it too.
|
|
217
226
|
|
|
218
227
|
### SelectBox
|
|
219
228
|
|
|
@@ -289,6 +298,10 @@ interface BottomNavTab {
|
|
|
289
298
|
|
|
290
299
|
Per-tab icons are the app's: style `.BottomNav__icon--<id>` with a `mask-image`. The bar's height is `--bottomnav-height` plus `--safe-area-inset-bottom`.
|
|
291
300
|
|
|
301
|
+
### SplitPane
|
|
302
|
+
|
|
303
|
+
Two panes side by side, the start one resized by dragging the handle between them. Props: `initialWidth` (default 150) and `minWidth` (default 100), the start pane's. Slots: `start`, `end`; each pane is a flex column whose children grow to fill it, the start one scrolling its overflow. Without an `end` slot (e.g. a conditional `<template v-if="…" #end>`) the start pane spans the whole width and no handle renders.
|
|
304
|
+
|
|
292
305
|
### Splash, Loading
|
|
293
306
|
|
|
294
307
|
`Splash` centres its default slot (logo) above a `Loading` indicator, covering its positioned ancestor. `Loading` renders `messages.loading` with animated dots.
|
|
@@ -297,7 +310,15 @@ Per-tab icons are the app's: style `.BottomNav__icon--<id>` with a `mask-image`.
|
|
|
297
310
|
|
|
298
311
|
### TreeView
|
|
299
312
|
|
|
300
|
-
Lazy tree rendered as one flat list of visible nodes. Props: `loader: TreeLoader` (required), `idField` (default `'key'`), `labelField` (default `'title'`), `multiSelect` (default `true`)
|
|
313
|
+
Lazy tree rendered as one flat list of visible nodes. Props: `loader: TreeLoader` (required), `idField` (default `'key'`), `labelField` (default `'title'`), `editField` (the field a rename edits; default `labelField`), `multiSelect` (default `true`), `draggable` (default `false`), `onRename?: (node: TreeNodeId, value: string) => Promise<void>`, `onAddChild?: (parent: TreeNodeId, value: string) => Promise<void>`. `v-model:selected: TreeNodeId[]`. Children load on first expand and are cached per node. Selection runs on `useSelection`: click selects, Shift extends a range over the visible list, Ctrl/Cmd toggles; the list is focusable, the arrows move the cursor (Shift extending the range) and Ctrl/Cmd+A selects all. A right-click focuses its row — selected alone unless it is selected already — and emits `contextmenu(event)`; the consumer opens its menu.
|
|
314
|
+
|
|
315
|
+
A node may have several parents and then shows under each: rows are keyed by their path from the root, while the children cache, expansion and selection stay per node. A node's loaded children decide whether its row expands, over the `expandable` its parent's load carried. The cursor's row is the focused occurrence (`aria-current`).
|
|
316
|
+
|
|
317
|
+
In-place editing: with `onRename`, double-clicking a label turns it into an input holding the node's `editField`; with `onAddChild`, `addChild()` opens an empty input last under the focused row. Enter hands a changed or non-empty, trimmed value to the callback and closes the input once it resolves — a rejection leaves it open — and Escape or leaving the input drops it. Keys typed there stay in the input.
|
|
318
|
+
|
|
319
|
+
Exposed: `reload(nodeId | null)` re-fetches a node's children (`null`: the root level), keeping what is expanded; `expand(nodeId)` expands a node wherever it shows, loading its children first; `levelsOf(nodeId)` lists the loaded levels holding a node — the parents it shows under, `null` for the root level — the levels to reload when it changes or goes; `focused` is the focused occurrence while its row is visible, else `null`; `addChild()`, above.
|
|
320
|
+
|
|
321
|
+
With `draggable`, rows drag onto rows: the upper third of the target row drops before it, the lower third after it, the middle inside it. A row whose path passes through the dragged node — any occurrence of it, or any row under one — is no target. The target row carries `data-drop` (`before` | `inside` | `after`) while hovered; the drop emits `move`, which the consumer carries out and reloads after.
|
|
301
322
|
|
|
302
323
|
```ts
|
|
303
324
|
interface TreeNode {
|
|
@@ -306,6 +327,14 @@ interface TreeNode {
|
|
|
306
327
|
}
|
|
307
328
|
type TreeNodeId = string | number
|
|
308
329
|
type TreeLoader = (parentId: TreeNodeId | null) => Promise<TreeNode[]> // null → root level
|
|
330
|
+
interface TreeOccurrence {
|
|
331
|
+
node: TreeNodeId
|
|
332
|
+
parent: TreeNodeId | null // null: at the root level
|
|
333
|
+
}
|
|
334
|
+
type TreeMove = { node: TreeNodeId; from: TreeNodeId | null } & (
|
|
335
|
+
| { position: 'inside'; to: TreeNodeId }
|
|
336
|
+
| { position: 'before' | 'after'; to: TreeNodeId | null; relativeTo: TreeNodeId }
|
|
337
|
+
)
|
|
309
338
|
```
|
|
310
339
|
|
|
311
340
|
### Accordion
|
|
@@ -331,7 +360,16 @@ type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<bool
|
|
|
331
360
|
|
|
332
361
|
### useSelection
|
|
333
362
|
|
|
334
|
-
`useSelection<Id extends string | number>(items: Ref<Record<string, unknown>[]> | ComputedRef<...>, idField: string)` — flat, index-addressable multi-selection over an ordered list.
|
|
363
|
+
`useSelection<Id extends string | number>(items: Ref<Record<string, unknown>[]> | ComputedRef<...>, idField: string, { keyField?: string, multi?: MaybeRefOrGetter<boolean> })` — flat, index-addressable multi-selection over an ordered list. The selection holds ids (`items[i][idField]`); the cursor and the anchor ranges extend from are rows, addressed by `items[i][keyField]` (default `idField`) — distinct when an item shows in several rows, as a tree node under two parents does. Without `multi` (default `true`), every pick and move selects one row. Returns:
|
|
364
|
+
|
|
365
|
+
- `selected: Set<Id>` (shallowReactive);
|
|
366
|
+
- `cursor: ShallowRef<string | number | null>`: the cursor's row key; setting it moves the cursor alone;
|
|
367
|
+
- `getId(index)`;
|
|
368
|
+
- `replace(key)`: selects the row alone, cursor and anchor on it;
|
|
369
|
+
- `pick(key, event: MouseEvent)`: a pointer's pick — Shift selects the range from the anchor (with no anchor in the list, it toggles), Ctrl/Cmd toggles the row, a plain pick replaces;
|
|
370
|
+
- `onKeyDown(event: KeyboardEvent): number`: Ctrl/Cmd+A selects all; ArrowUp/ArrowDown move the cursor, selecting its row alone, or with Shift the range from the anchor to it. Returns the cursor's new index, for the host to scroll into view, or -1.
|
|
371
|
+
|
|
372
|
+
`InfiniteScrollTable`, `RowSelect` and `TreeView` run on it.
|
|
335
373
|
|
|
336
374
|
### RowSelect
|
|
337
375
|
|
|
@@ -339,6 +377,8 @@ type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<bool
|
|
|
339
377
|
|
|
340
378
|
## Misc
|
|
341
379
|
|
|
380
|
+
- `InfoBlock` — a notice set apart by `--infoblock-line-color` and the `info` glyph; an `actions` slot stacks controls beneath the message.
|
|
381
|
+
- `SwitchRow` — an on/off switch shaped as a row (`v-model`, `label`, `title`): an `<a role="switch">`, so it sits among menu items, its layout the host class's.
|
|
342
382
|
- `CircularProgress` — SVG ring, prop `progress: number` (0–100, clamped), `role="progressbar"`; colours via `--circular-progress-track` / `--circular-progress-arc` / `--circular-progress-label`.
|
|
343
383
|
- `Copy` — click-to-copy icon for `value: string`; `showValue` renders the value beside it; a `copied` class marks success for 2s. Title is `messages.clickToCopy`.
|
|
344
384
|
- `copyTextToClipboard(text: string): Promise<void>` — `navigator.clipboard.writeText`; requires a secure context.
|
|
@@ -346,3 +386,5 @@ type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<bool
|
|
|
346
386
|
- `scrollToTop()` — `window.scroll({ top: 0 })`.
|
|
347
387
|
- `focusIn(el: string | HTMLElement | null, selector?: string)` — focuses the element with that id, or the first `selector` match inside `el`, unless it already has focus.
|
|
348
388
|
- `TOUCH_PAN_SLOP_PX` — 10; the travel a touch must cover before it counts as a directional gesture; exported so other gesture-arbitrating components share it.
|
|
389
|
+
- `capturePointer(event: PointerEvent)` — captures the pointer to `event.currentTarget`, so a drag keeps tracking it outside the element; a pointer already released is a no-op.
|
|
390
|
+
- `useWidthDrag(minWidth: MaybeRefOrGetter<number>): WidthDrag` — a width dragged by a handle at its end edge, never below `minWidth`. Returns `{ active, start(event, width, set), move(event), end() }`: wire `start` to the handle's `pointerdown` (it captures the pointer and drags from `width`, handing each new width to `set`), `move` to `pointermove`, `end` to `pointerup` and `pointercancel`; `active` is true while a drag is in progress. `SplitPane` uses it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@greendrake/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Vue 3 component library: form controls, modals, search, tabs, toasts, layout",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
},
|
|
34
34
|
"prettier": "@greendrake/dev-config/prettier",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@greendrake/domains": "0.
|
|
37
|
-
"@greendrake/scss-kit": "0.
|
|
38
|
-
"@greendrake/util": "0.
|
|
36
|
+
"@greendrake/domains": "^0.7.0",
|
|
37
|
+
"@greendrake/scss-kit": "^0.8.0",
|
|
38
|
+
"@greendrake/util": "^0.8.0",
|
|
39
39
|
"vue-tippy": "^6.7.1"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"vue-router": "^4.6.4"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@greendrake/dev-config": "0.
|
|
46
|
+
"@greendrake/dev-config": "0.7.0",
|
|
47
47
|
"typescript": "6.0.3",
|
|
48
48
|
"vue": "^3.5.34",
|
|
49
49
|
"vue-router": "^4.6.4",
|
package/src/BottomNav.vue
CHANGED
|
@@ -78,11 +78,11 @@ const emit = defineEmits<{ select: [id: string] }>()
|
|
|
78
78
|
color: var(--font-color-muted, var(--font-color));
|
|
79
79
|
text-decoration: none;
|
|
80
80
|
// A tab is a link or, when it opens a shell overlay, a button — which
|
|
81
|
-
// arrives with a UA background, border and font of its own
|
|
81
|
+
// arrives with a UA background, border and font of its own, and keeps the
|
|
82
|
+
// default cursor where the link has the hand.
|
|
82
83
|
background: none;
|
|
83
84
|
border: none;
|
|
84
85
|
font: inherit;
|
|
85
|
-
cursor: pointer;
|
|
86
86
|
&--active {
|
|
87
87
|
color: var(--accent-color);
|
|
88
88
|
.BottomNav__icon {
|
package/src/CircularProgress.vue
CHANGED
|
@@ -8,7 +8,11 @@ const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
|
|
8
8
|
<script setup lang="ts">
|
|
9
9
|
import { computed } from 'vue'
|
|
10
10
|
|
|
11
|
-
const props = defineProps<{
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
progress: number
|
|
13
|
+
/** Replaces the default percentage text (e.g. seconds left). */
|
|
14
|
+
label?: string
|
|
15
|
+
}>()
|
|
12
16
|
|
|
13
17
|
const clamped = computed(() => Math.max(0, Math.min(100, props.progress)))
|
|
14
18
|
// Full circumference at 0%, zero at 100% — the dash grows clockwise from the
|
|
@@ -19,7 +23,7 @@ const dashOffset = computed(() => CIRCUMFERENCE * (1 - clamped.value / 100))
|
|
|
19
23
|
<svg class="CircularProgress" viewBox="0 0 36 36" role="progressbar" :aria-valuenow="Math.round(clamped)" aria-valuemin="0" aria-valuemax="100">
|
|
20
24
|
<circle class="CircularProgress__track" cx="18" cy="18" :r="RADIUS" />
|
|
21
25
|
<circle class="CircularProgress__arc" cx="18" cy="18" :r="RADIUS" :stroke-dasharray="CIRCUMFERENCE" :stroke-dashoffset="dashOffset" />
|
|
22
|
-
<text class="CircularProgress__label" x="18" y="18">{{ Math.round(clamped) }}
|
|
26
|
+
<text class="CircularProgress__label" x="18" y="18">{{ label ?? `${Math.round(clamped)}%` }}</text>
|
|
23
27
|
</svg>
|
|
24
28
|
</template>
|
|
25
29
|
<style lang="scss">
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** A context menu entry: its label, and what choosing it does. */
|
|
3
|
+
export interface ContextMenuItem {
|
|
4
|
+
label: string
|
|
5
|
+
run: () => unknown
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const appendTo = () => document.body
|
|
9
|
+
</script>
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
import { ref } from 'vue'
|
|
12
|
+
import { Tippy, type TippyComponent } from 'vue-tippy'
|
|
13
|
+
|
|
14
|
+
// A menu at the pointer in place of the browser's own: open(event) from a
|
|
15
|
+
// contextmenu listener shows it there. Choosing an item closes it and runs
|
|
16
|
+
// the item; a press outside it, or any key, closes it.
|
|
17
|
+
defineProps<{
|
|
18
|
+
items: ContextMenuItem[]
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const tip = ref<TippyComponent>()
|
|
22
|
+
let at = new DOMRect()
|
|
23
|
+
const pointer = (): DOMRect => at
|
|
24
|
+
|
|
25
|
+
// Any key closes the menu while it shows. Unmounted while it shows, it is
|
|
26
|
+
// destroyed without hiding: that ends the listening too.
|
|
27
|
+
const hide = (): void => tip.value!.hide()
|
|
28
|
+
const listen = (): void => document.addEventListener('keydown', hide)
|
|
29
|
+
const unlisten = (): void => document.removeEventListener('keydown', hide)
|
|
30
|
+
|
|
31
|
+
function open(e: MouseEvent): void {
|
|
32
|
+
e.preventDefault()
|
|
33
|
+
at = new DOMRect(e.clientX, e.clientY, 0, 0)
|
|
34
|
+
tip.value!.show()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function choose(item: ContextMenuItem): void {
|
|
38
|
+
hide()
|
|
39
|
+
item.run()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
defineExpose({ open })
|
|
43
|
+
</script>
|
|
44
|
+
<template>
|
|
45
|
+
<Tippy ref="tip" to="parent" trigger="manual" theme="ContextMenu" role="menu" placement="bottom-start" interactive :append-to="appendTo" :aria="{ content: null, expanded: false }" :arrow="false" :offset="[0, 0]" :duration="0" :get-reference-client-rect="pointer" :on-show="listen" :on-hide="unlisten" :on-destroy="unlisten">
|
|
46
|
+
<button v-for="item in items" :key="item.label" type="button" role="menuitem" class="ContextMenu__item" @click="choose(item)">{{ item.label }}</button>
|
|
47
|
+
</Tippy>
|
|
48
|
+
</template>
|
|
49
|
+
<style lang="scss">
|
|
50
|
+
.tippy-box[data-theme~='ContextMenu'] {
|
|
51
|
+
min-width: 120px;
|
|
52
|
+
padding: var(--space-1) 0;
|
|
53
|
+
border: 1px solid var(--border-color-light);
|
|
54
|
+
background: var(--form-background);
|
|
55
|
+
.tippy-content {
|
|
56
|
+
padding: 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
.ContextMenu__item {
|
|
60
|
+
display: block;
|
|
61
|
+
width: 100%;
|
|
62
|
+
margin: 0;
|
|
63
|
+
padding: var(--space-1) var(--space-3);
|
|
64
|
+
border: none;
|
|
65
|
+
border-radius: 0;
|
|
66
|
+
background: none;
|
|
67
|
+
color: var(--font-color);
|
|
68
|
+
font: inherit;
|
|
69
|
+
text-align: start;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
cursor: default;
|
|
72
|
+
|
|
73
|
+
&:hover {
|
|
74
|
+
background: var(--form-background-hover);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
</style>
|
package/src/Copy.vue
CHANGED
|
@@ -26,7 +26,8 @@ const onClick = async (e: MouseEvent) => {
|
|
|
26
26
|
</script>
|
|
27
27
|
<template>
|
|
28
28
|
<div class="Copy2Clipboard" :title="messages.clickToCopy" @click="onClick">
|
|
29
|
-
|
|
29
|
+
<!-- The slot renders the value differently from the text copied. -->
|
|
30
|
+
<span v-if="showValue" class="Copy2Clipboard__value"><slot>{{ value }}</slot></span>
|
|
30
31
|
</div>
|
|
31
32
|
</template>
|
|
32
33
|
<style lang="scss">
|
package/src/FieldTip.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
/// <reference path="./styles.d.ts" />
|
|
7
7
|
import { watch, onUnmounted, type Ref, type ComponentPublicInstance } from 'vue'
|
|
8
8
|
import { useTippy } from 'vue-tippy'
|
|
9
|
-
// Self-contained box/arrow styling for these tips, scoped to the `
|
|
10
|
-
//
|
|
11
|
-
import './
|
|
9
|
+
// Self-contained box/arrow styling for these tips, scoped to the `tip` theme applied
|
|
10
|
+
// below (the app ships no global tippy.js base stylesheet).
|
|
11
|
+
import './Tip.scss'
|
|
12
12
|
|
|
13
13
|
type Tooltip = ReturnType<typeof useTippy>
|
|
14
14
|
|
|
@@ -44,7 +44,7 @@ export default (toWatchRef: Ref<string | false>, targetRef: string | Ref<HTMLEle
|
|
|
44
44
|
} else if (content && el) {
|
|
45
45
|
tooltip = useTippy(el, {
|
|
46
46
|
content,
|
|
47
|
-
theme: '
|
|
47
|
+
theme: 'tip',
|
|
48
48
|
trigger: 'manual',
|
|
49
49
|
interactive: true,
|
|
50
50
|
showOnCreate: true,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A notice set apart from the content around it, marked with the info glyph;
|
|
3
|
+
// optional controls (an `actions` slot) stack beneath the message.
|
|
4
|
+
</script>
|
|
5
|
+
<template>
|
|
6
|
+
<div class="InfoBlock">
|
|
7
|
+
<slot />
|
|
8
|
+
<div v-if="$slots.actions" class="InfoBlock__actions">
|
|
9
|
+
<slot name="actions" />
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
</template>
|
|
13
|
+
<style lang="scss">
|
|
14
|
+
@use '@greendrake/scss-kit' as *;
|
|
15
|
+
.InfoBlock {
|
|
16
|
+
--icon-space-width: 50px;
|
|
17
|
+
margin-top: var(--space-4);
|
|
18
|
+
position: relative;
|
|
19
|
+
padding: var(--space-4) var(--space-4) var(--space-4) var(--icon-space-width);
|
|
20
|
+
border: 1px solid var(--infoblock-line-color);
|
|
21
|
+
border-radius: var(--border-radius);
|
|
22
|
+
line-height: var(--line-height-tight);
|
|
23
|
+
&::before {
|
|
24
|
+
content: '';
|
|
25
|
+
position: absolute;
|
|
26
|
+
inset-inline-start: 0;
|
|
27
|
+
top: 0;
|
|
28
|
+
bottom: 0;
|
|
29
|
+
width: var(--icon-space-width);
|
|
30
|
+
mask-size: 24px;
|
|
31
|
+
mask-position: center 14px;
|
|
32
|
+
@include icon-base;
|
|
33
|
+
@include i('info');
|
|
34
|
+
}
|
|
35
|
+
// Optional controls (e.g. an escalation note + button) rendered within the
|
|
36
|
+
// block, separated from the message and stacked left-aligned.
|
|
37
|
+
&__actions {
|
|
38
|
+
margin-top: var(--space-3);
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
align-items: stretch;
|
|
42
|
+
gap: var(--space-2);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
</style>
|
package/src/InfoTip.vue
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A mark beside something worth explaining: its text shows in a tooltip on hover, on
|
|
3
|
+
// focus, and on a tap where there is no hover. A button, so that a keyboard reaches it;
|
|
4
|
+
// placed beside a field's <label> rather than in it, where it would become the control
|
|
5
|
+
// the label names.
|
|
6
|
+
import { Tippy } from 'vue-tippy'
|
|
7
|
+
import { messages } from './messages'
|
|
8
|
+
import './Tip.scss'
|
|
9
|
+
|
|
10
|
+
defineProps<{ text: string }>()
|
|
11
|
+
</script>
|
|
12
|
+
<template>
|
|
13
|
+
<Tippy :content="text" theme="tip" placement="top" trigger="mouseenter focus" class="InfoTip">
|
|
14
|
+
<button type="button" class="InfoTip__mark" :aria-label="messages.aboutThis">?</button>
|
|
15
|
+
</Tippy>
|
|
16
|
+
</template>
|
|
17
|
+
<style lang="scss">
|
|
18
|
+
.InfoTip__mark {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
width: 18px;
|
|
23
|
+
height: 18px;
|
|
24
|
+
padding: 0;
|
|
25
|
+
border: 1px solid var(--border-color);
|
|
26
|
+
border-radius: 50%;
|
|
27
|
+
background: transparent;
|
|
28
|
+
color: var(--font-color-muted, var(--font-color));
|
|
29
|
+
font-size: var(--font-xs);
|
|
30
|
+
line-height: 1;
|
|
31
|
+
cursor: help;
|
|
32
|
+
}
|
|
33
|
+
</style>
|
package/src/MenuButton.vue
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// coordinates as top/left.
|
|
9
9
|
const popperOptions = { modifiers: [{ name: 'computeStyles', options: { gpuAcceleration: false } }] }
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// The body, or the element filling the screen while one does, outside which nothing
|
|
12
|
+
// shows: a menu opened in a fullscreen video player, say. Asked on every show.
|
|
13
|
+
const appendTo = () => document.fullscreenElement ?? document.body
|
|
12
14
|
</script>
|
|
13
15
|
<script setup lang="ts">
|
|
14
16
|
import { computed } from 'vue'
|
package/src/ModalOnClick.vue
CHANGED
|
@@ -5,8 +5,13 @@ import Modal from './Modal.vue'
|
|
|
5
5
|
// Trigger-plus-modal composite: the default slot is the trigger element;
|
|
6
6
|
// clicking it opens the built-in Modal around the `content` slot. The modal
|
|
7
7
|
// chrome props mirror Modal's and are forwarded as-is — undefined falls
|
|
8
|
-
// through to Modal's own defaults; `modalClass`
|
|
9
|
-
// class/$attrs on this component land on the trigger element.
|
|
8
|
+
// through to Modal's own defaults; `modalClass` and `modalTestid` are distinct
|
|
9
|
+
// props because class/$attrs on this component land on the trigger element.
|
|
10
|
+
//
|
|
11
|
+
// The modal is nested in the trigger so that the trigger is the single root:
|
|
12
|
+
// only a single root inherits $attrs and the parent's scoped-style id. Modal
|
|
13
|
+
// teleports to body, so neither its DOM nor its clicks end up inside the
|
|
14
|
+
// trigger.
|
|
10
15
|
//
|
|
11
16
|
// `ok` is re-emitted WITHOUT auto-closing: async submitters keep the modal
|
|
12
17
|
// open on failure and close programmatically (exposed onCancel) on success.
|
|
@@ -21,7 +26,9 @@ withDefaults(
|
|
|
21
26
|
// accepts them at call sites.
|
|
22
27
|
id?: string
|
|
23
28
|
type?: string
|
|
29
|
+
ariaLabel?: string
|
|
24
30
|
modalClass?: string
|
|
31
|
+
modalTestid?: string
|
|
25
32
|
okLabel?: string
|
|
26
33
|
cancelLabel?: string
|
|
27
34
|
okDisabled?: boolean
|
|
@@ -64,10 +71,10 @@ defineExpose({
|
|
|
64
71
|
})
|
|
65
72
|
</script>
|
|
66
73
|
<template>
|
|
67
|
-
<
|
|
68
|
-
<slot name="content" />
|
|
69
|
-
</Modal>
|
|
70
|
-
<component :is="tag" :id="id" :type="type" href="#" v-bind="$attrs" @click.prevent="onClick">
|
|
74
|
+
<component :is="tag" :id="id" :type="type" :aria-label="ariaLabel" href="#" @click.prevent="onClick">
|
|
71
75
|
<slot />
|
|
76
|
+
<Modal v-if="modalOpen" :class="modalClass" :data-testid="modalTestid" :title="title" :ok-label="okLabel" :cancel-label="cancelLabel" :ok-disabled="okDisabled" :hide-ok="hideOk" :closable="closable" :expandable="expandable" :buttons="buttons" @ok="emit('ok')" @cancel="onModalCancel">
|
|
77
|
+
<slot name="content" />
|
|
78
|
+
</Modal>
|
|
72
79
|
</component>
|
|
73
80
|
</template>
|
package/src/RowSelect.ts
CHANGED
|
@@ -155,19 +155,11 @@ export default function RowSelect<Id extends string | number>(items: Ref<Record<
|
|
|
155
155
|
const rowIndex = hitIndex(target)
|
|
156
156
|
const id = rowIndex >= 0 ? getId(rowIndex) : null
|
|
157
157
|
|
|
158
|
-
if (id !== null && e.shiftKey) {
|
|
159
|
-
sel.rangeTo(id)
|
|
160
|
-
return
|
|
161
|
-
}
|
|
162
|
-
|
|
163
158
|
preClickSnapshot = new Set(selected)
|
|
164
|
-
|
|
165
159
|
if (id !== null) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
sel.replace(id)
|
|
170
|
-
}
|
|
160
|
+
sel.pick(id, e)
|
|
161
|
+
// A Shift pick extends the selection only: no drag follows.
|
|
162
|
+
if (e.shiftKey) return
|
|
171
163
|
}
|
|
172
164
|
|
|
173
165
|
startX = e.clientX
|
|
@@ -181,21 +173,6 @@ export default function RowSelect<Id extends string | number>(items: Ref<Record<
|
|
|
181
173
|
}
|
|
182
174
|
}
|
|
183
175
|
|
|
184
|
-
function onKeyDown(e: KeyboardEvent): void {
|
|
185
|
-
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
186
|
-
e.preventDefault()
|
|
187
|
-
sel.selectAll()
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const isUp = e.key === 'ArrowUp'
|
|
192
|
-
const isDown = e.key === 'ArrowDown'
|
|
193
|
-
if (!isUp && !isDown) return
|
|
194
|
-
|
|
195
|
-
e.preventDefault()
|
|
196
|
-
sel.moveCursor(isUp ? 'up' : 'down', e.shiftKey)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
176
|
onUnmounted(() => {
|
|
200
177
|
if (cleanupDrag) cleanupDrag()
|
|
201
178
|
})
|
|
@@ -206,6 +183,6 @@ export default function RowSelect<Id extends string | number>(items: Ref<Record<
|
|
|
206
183
|
band,
|
|
207
184
|
reorder,
|
|
208
185
|
onPointerDown,
|
|
209
|
-
onKeyDown
|
|
186
|
+
onKeyDown: sel.onKeyDown
|
|
210
187
|
}
|
|
211
188
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Two panes side by side, the start one resized by dragging the handle between
|
|
3
|
+
// them. Without an `end` slot the start pane spans the whole width alone.
|
|
4
|
+
import { ref } from 'vue'
|
|
5
|
+
import useWidthDrag from './useWidthDrag'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(
|
|
8
|
+
defineProps<{
|
|
9
|
+
initialWidth?: number
|
|
10
|
+
minWidth?: number
|
|
11
|
+
}>(),
|
|
12
|
+
{
|
|
13
|
+
initialWidth: 150,
|
|
14
|
+
minWidth: 100
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const width = ref(props.initialWidth)
|
|
19
|
+
const { active: resizing, start, move, end } = useWidthDrag(() => props.minWidth)
|
|
20
|
+
</script>
|
|
21
|
+
<template>
|
|
22
|
+
<div class="SplitPane" :class="{ 'SplitPane--resizing': resizing }">
|
|
23
|
+
<div class="SplitPane__start" :style="$slots.end ? { width: width + 'px' } : { flex: '1 1 0', width: 0 }">
|
|
24
|
+
<slot name="start"></slot>
|
|
25
|
+
</div>
|
|
26
|
+
<template v-if="$slots.end">
|
|
27
|
+
<div class="SplitPane__handle" @pointerdown="start($event, width, w => (width = w))" @pointermove="move" @pointerup="end" @pointercancel="end"></div>
|
|
28
|
+
<div class="SplitPane__end">
|
|
29
|
+
<slot name="end"></slot>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
<style lang="scss">
|
|
35
|
+
@use '@greendrake/scss-kit' as *;
|
|
36
|
+
|
|
37
|
+
.SplitPane {
|
|
38
|
+
display: flex;
|
|
39
|
+
min-width: 0;
|
|
40
|
+
// Flex item in a column parent: without this the automatic min-height (auto)
|
|
41
|
+
// keeps it as tall as its content, so it overgrows the viewport and the
|
|
42
|
+
// panes' overflow has nothing to scroll within.
|
|
43
|
+
min-height: 0;
|
|
44
|
+
|
|
45
|
+
&--resizing {
|
|
46
|
+
@include dragging(col-resize);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&__start,
|
|
50
|
+
&__end {
|
|
51
|
+
min-height: 0;
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
|
|
55
|
+
> * {
|
|
56
|
+
flex-grow: 1;
|
|
57
|
+
min-height: 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
&__start {
|
|
62
|
+
flex-shrink: 0;
|
|
63
|
+
min-width: v-bind('minWidth + "px"');
|
|
64
|
+
overflow: auto;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
&__handle {
|
|
68
|
+
width: 4px;
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
cursor: col-resize;
|
|
71
|
+
// The drag is handled via pointer events; without this the browser
|
|
72
|
+
// claims touch drags for panning and cancels them.
|
|
73
|
+
touch-action: none;
|
|
74
|
+
background-color: var(--border-color-light);
|
|
75
|
+
&:hover {
|
|
76
|
+
background-color: var(--accent-color);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
&__end {
|
|
81
|
+
flex-grow: 1;
|
|
82
|
+
min-width: 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
</style>
|