@greendrake/ui 0.6.0 → 0.7.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 CHANGED
@@ -27,13 +27,13 @@ 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`, `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
- | `--accent-color` | active and checked states: `BottomNav`, `Checkbox`, `SegmentedControl`, `TabBar`, toasts |
36
+ | `--accent-color` | active and checked states: `BottomNav`, `Checkbox`, `SegmentedControl`, `TabBar`, toasts; the hovered `SplitPane` handle; `TreeView` drop targets |
37
37
  | `--space-1` … `--space-5` | spacing |
38
38
  | `--font-xs`, `--font-md`, `--font-lg` | `BottomNav` and `ThumbnailGrid` labels, `FieldTip`, `Modal` title |
39
39
  | `--line-height-normal` | `Password` |
@@ -53,14 +53,14 @@ Read with a local default:
53
53
  | `--bottomnav-height` | `BottomNav` bar height; bottom edge of the `AsyncSearch` narrow panel | none / 0px |
54
54
  | `--safe-area-inset-top`, `--safe-area-inset-bottom` | toast layer; `Modal` sheet and `BottomNav` padding | 0px |
55
55
  | `--max-content-width` | `TheWrap` width cap | none |
56
- | `--tippy-background-color` | `FieldTip` validation tips | `#5335e8` |
56
+ | `--tippy-background-color` | `FieldTip` validation tips, `InfoTip` tooltips | `#5335e8` |
57
57
  | `--toast-color`, `--toast-background` | toasts | `#fff`; `--accent-color` (`--warning-color` for the warning variant) |
58
58
  | `--color-attention-fill`, `--color-attention-contrast` | `BottomNav` badge | `--accent-color` / `#fff` |
59
59
  | `--vs-*` | `SelectBox` (see the component) | per property |
60
60
 
61
61
  Breakpoint: "narrow" is `@greendrake/scss-kit`'s 600px boundary; `Modal`, `TabBar` and `AsyncSearch` switch presentation below it.
62
62
 
63
- Tooltips: `FieldTip`, `MenuButton` and `AsyncSearch` use `vue-tippy`. Each ships the styles of its own tippy theme (`fieldTip`, `HMenu`, `asyncSearchResults`); no global tippy.js stylesheet is loaded or required.
63
+ 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
64
 
65
65
  ## Messages
66
66
 
@@ -74,6 +74,8 @@ configureUiMessages({ cancel: 'Abbrechen', nothingFound: 'Nichts gefunden' })
74
74
 
75
75
  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
76
 
77
+ `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).
78
+
77
79
  ## Form controls
78
80
 
79
81
  ### TextField
@@ -123,7 +125,9 @@ Caption + control column. Props: `label`; `group` wraps the slot in a `role="gro
123
125
 
124
126
  ### FieldTip
125
127
 
126
- `FieldTip(source: Ref<string | false>, target: string | Ref<HTMLElement | ComponentPublicInstance | null>): void` — binds a manually-triggered tippy tooltip (theme `fieldTip`) 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.
128
+ `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.
129
+
130
+ `<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
131
 
128
132
  ### validationScopeKey
129
133
 
@@ -203,7 +207,7 @@ const save = async (): Promise<void> => {
203
207
 
204
208
  ### ModalOnClick
205
209
 
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` goes 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()`.
210
+ 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
211
 
208
212
  ### Gallery
209
213
 
@@ -215,6 +219,10 @@ Full-screen image viewer in an expanded `Modal`. Props: `items: GalleryItem[]` (
215
219
 
216
220
  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).
217
221
 
222
+ ### ContextMenu
223
+
224
+ 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.
225
+
218
226
  ### SelectBox
219
227
 
220
228
  `v-model: T | null` (`T extends string | number`; `null` = no selection, shows the placeholder). Single-select dropdown with keyboard support (arrows, Enter, Escape closes the menu without reaching a hosting modal). Props: `options: SelectBoxOption<T>[]` (`{ value, label, disabled?, depth? }` — `depth` indents the option in the menu), `placeholder` (default `messages.selectOption`), `ariaLabel`, `clearable`, `searchable` (type-to-filter input while open), `disabled`, `htmlLabel` (labels via `v-html`; trusted content only). Emits `pick(value)` on every menu selection, including re-picking the current one. Exposes `open()`. The menu is positioned against the viewport, flips upward or clamps its height when fixed chrome would cover it, and follows scroll and on-screen-keyboard changes.
@@ -289,6 +297,10 @@ interface BottomNavTab {
289
297
 
290
298
  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
299
 
300
+ ### SplitPane
301
+
302
+ 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.
303
+
292
304
  ### Splash, Loading
293
305
 
294
306
  `Splash` centres its default slot (logo) above a `Loading` indicator, covering its positioned ancestor. `Loading` renders `messages.loading` with animated dots.
@@ -297,7 +309,15 @@ Per-tab icons are the app's: style `.BottomNav__icon--<id>` with a `mask-image`.
297
309
 
298
310
  ### TreeView
299
311
 
300
- Lazy tree rendered as one flat list of visible nodes. Props: `loader: TreeLoader` (required), `idField` (default `'key'`), `labelField` (default `'title'`), `multiSelect` (default `true`). `v-model:selected: TreeNodeId[]`. Children load on first expand and are cached per node. Selection is mouse-driven: click selects, Shift extends a range over the visible list, Ctrl/Cmd toggles.
312
+ 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.
313
+
314
+ 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`).
315
+
316
+ 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.
317
+
318
+ 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.
319
+
320
+ 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
321
 
302
322
  ```ts
303
323
  interface TreeNode {
@@ -306,6 +326,14 @@ interface TreeNode {
306
326
  }
307
327
  type TreeNodeId = string | number
308
328
  type TreeLoader = (parentId: TreeNodeId | null) => Promise<TreeNode[]> // null → root level
329
+ interface TreeOccurrence {
330
+ node: TreeNodeId
331
+ parent: TreeNodeId | null // null: at the root level
332
+ }
333
+ type TreeMove = { node: TreeNodeId; from: TreeNodeId | null } & (
334
+ | { position: 'inside'; to: TreeNodeId }
335
+ | { position: 'before' | 'after'; to: TreeNodeId | null; relativeTo: TreeNodeId }
336
+ )
309
337
  ```
310
338
 
311
339
  ### Accordion
@@ -331,7 +359,16 @@ type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<bool
331
359
 
332
360
  ### useSelection
333
361
 
334
- `useSelection<Id extends string | number>(items: Ref<Record<string, unknown>[]> | ComputedRef<...>, idField: string)` — flat, index-addressable multi-selection over an ordered list. Returns `{ selected: Set<Id> (shallowReactive), getId(index), indexOf(id), addRange(fromId, toId), toggle(id), replace(id), rangeTo(id) (from the anchor set by the last toggle/replace), moveCursor('up' | 'down', extend: boolean): number (returns the new cursor index), selectAll(), clear() }`. `TreeView` uses it.
362
+ `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:
363
+
364
+ - `selected: Set<Id>` (shallowReactive);
365
+ - `cursor: ShallowRef<string | number | null>`: the cursor's row key; setting it moves the cursor alone;
366
+ - `getId(index)`;
367
+ - `replace(key)`: selects the row alone, cursor and anchor on it;
368
+ - `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;
369
+ - `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.
370
+
371
+ `InfiniteScrollTable`, `RowSelect` and `TreeView` run on it.
335
372
 
336
373
  ### RowSelect
337
374
 
@@ -339,6 +376,8 @@ type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<bool
339
376
 
340
377
  ## Misc
341
378
 
379
+ - `InfoBlock` — a notice set apart by `--infoblock-line-color` and the `info` glyph; an `actions` slot stacks controls beneath the message.
380
+ - `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
381
  - `CircularProgress` — SVG ring, prop `progress: number` (0–100, clamped), `role="progressbar"`; colours via `--circular-progress-track` / `--circular-progress-arc` / `--circular-progress-label`.
343
382
  - `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
383
  - `copyTextToClipboard(text: string): Promise<void>` — `navigator.clipboard.writeText`; requires a secure context.
@@ -346,3 +385,5 @@ type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<bool
346
385
  - `scrollToTop()` — `window.scroll({ top: 0 })`.
347
386
  - `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
387
  - `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.
388
+ - `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.
389
+ - `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.6.0",
3
+ "version": "0.7.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.6.0",
37
- "@greendrake/scss-kit": "0.6.0",
38
- "@greendrake/util": "0.6.0",
36
+ "@greendrake/domains": "0.7.0",
37
+ "@greendrake/scss-kit": "0.7.0",
38
+ "@greendrake/util": "0.7.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.6.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 {
@@ -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<{ progress: number }>()
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) }}%</text>
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
- <span v-if="showValue" class="Copy2Clipboard__value">{{ value }}</span>
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 `fieldTip` theme
10
- // applied below (the app ships no global tippy.js base stylesheet).
11
- import './FieldTip.scss'
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: 'fieldTip',
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>
@@ -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>
@@ -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` is a distinct prop because
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
- <Modal v-if="modalOpen" :class="modalClass" :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">
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
- if (e.ctrlKey || e.metaKey) {
167
- sel.toggle(id)
168
- } else {
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>