@greendrake/ui 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -0
  3. package/package.json +52 -0
  4. package/src/Accordion.vue +84 -0
  5. package/src/AsyncAction.vue +66 -0
  6. package/src/AsyncSearch.vue +262 -0
  7. package/src/AsyncSearchResults.vue +74 -0
  8. package/src/BottomNav.vue +125 -0
  9. package/src/Checkbox.vue +112 -0
  10. package/src/CircularProgress.vue +57 -0
  11. package/src/Copy.vue +51 -0
  12. package/src/CurrencyInput.vue +143 -0
  13. package/src/Email.vue +17 -0
  14. package/src/Field.vue +54 -0
  15. package/src/FieldTip.scss +76 -0
  16. package/src/FieldTip.ts +57 -0
  17. package/src/FixAndroidTyping.ts +13 -0
  18. package/src/Form.ts +124 -0
  19. package/src/Form.vue +78 -0
  20. package/src/Gallery.vue +174 -0
  21. package/src/Loading.vue +32 -0
  22. package/src/MenuButton.vue +95 -0
  23. package/src/Modal.vue +365 -0
  24. package/src/ModalOnClick.vue +73 -0
  25. package/src/OptionSwitch.vue +110 -0
  26. package/src/Password.vue +85 -0
  27. package/src/RowSelect.ts +211 -0
  28. package/src/SearchField.vue +124 -0
  29. package/src/SegmentedControl.vue +85 -0
  30. package/src/SelectBox.vue +468 -0
  31. package/src/Splash.vue +36 -0
  32. package/src/TabBar.vue +158 -0
  33. package/src/TextField.vue +116 -0
  34. package/src/TheWrap.vue +30 -0
  35. package/src/ThumbnailGrid.vue +166 -0
  36. package/src/TreeView.vue +308 -0
  37. package/src/_shared.scss +22 -0
  38. package/src/copyTextToClipboard.ts +3 -0
  39. package/src/downsizeImage.ts +78 -0
  40. package/src/inModal.ts +8 -0
  41. package/src/main.ts +78 -0
  42. package/src/messages.ts +39 -0
  43. package/src/modalStack.ts +181 -0
  44. package/src/styles.d.ts +6 -0
  45. package/src/toast/ToastShell.vue +70 -0
  46. package/src/toast/showToast.ts +81 -0
  47. package/src/touchSlop.ts +6 -0
  48. package/src/useSelection.ts +99 -0
  49. package/src/validationScope.ts +6 -0
@@ -0,0 +1,78 @@
1
+ interface Options {
2
+ maxDimension?: number
3
+ maxSizeKB?: number
4
+ minQuality?: number
5
+ }
6
+
7
+ const DEFAULTS = {
8
+ maxDimension: 2000,
9
+ maxSizeKB: 1024,
10
+ minQuality: 0.2
11
+ }
12
+
13
+ const canvasToBlob = (canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> =>
14
+ new Promise((resolve, reject) => {
15
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('toBlob returned null'))), type, quality)
16
+ })
17
+
18
+ const loadImage = (file: File): Promise<HTMLImageElement> =>
19
+ new Promise((resolve, reject) => {
20
+ const url = URL.createObjectURL(file)
21
+ const img = new Image()
22
+ img.onload = () => {
23
+ URL.revokeObjectURL(url)
24
+ resolve(img)
25
+ }
26
+ img.onerror = () => {
27
+ URL.revokeObjectURL(url)
28
+ reject(new Error('Failed to load image'))
29
+ }
30
+ img.src = url
31
+ })
32
+
33
+ // Re-encodes an oversized image as JPEG, scaling it to fit maxDimension and
34
+ // stepping quality down until it fits maxSizeKB (or minQuality is reached).
35
+ // Non-images and files already within both limits pass through untouched, as
36
+ // do files the browser cannot decode.
37
+ export default async function downsizeImage(file: File, opts?: Options): Promise<File> {
38
+ if (!file.type.startsWith('image/')) return file
39
+
40
+ const { maxDimension, maxSizeKB, minQuality } = { ...DEFAULTS, ...opts }
41
+ const maxSizeBytes = maxSizeKB * 1024
42
+
43
+ let img: HTMLImageElement
44
+ try {
45
+ img = await loadImage(file)
46
+ } catch {
47
+ return file
48
+ }
49
+ let { width, height } = img
50
+ if (file.size <= maxSizeBytes && width <= maxDimension && height <= maxDimension) {
51
+ return file
52
+ }
53
+
54
+ if (width > maxDimension || height > maxDimension) {
55
+ const ratio = Math.min(maxDimension / width, maxDimension / height)
56
+ width = Math.round(width * ratio)
57
+ height = Math.round(height * ratio)
58
+ }
59
+
60
+ const canvas = document.createElement('canvas')
61
+ canvas.width = width
62
+ canvas.height = height
63
+ const ctx = canvas.getContext('2d')!
64
+ ctx.drawImage(img, 0, 0, width, height)
65
+
66
+ let quality = 0.92
67
+ let blob = await canvasToBlob(canvas, 'image/jpeg', quality)
68
+
69
+ while (blob.size > maxSizeBytes && quality > minQuality) {
70
+ quality -= 0.05
71
+ blob = await canvasToBlob(canvas, 'image/jpeg', quality)
72
+ }
73
+
74
+ canvas.remove()
75
+
76
+ const name = file.name.replace(/\.[^.]+$/, '.jpg')
77
+ return new File([blob], name, { type: 'image/jpeg' })
78
+ }
package/src/inModal.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { InjectionKey } from 'vue'
2
+
3
+ // Modal provides this so descendants can tell they render inside a modal
4
+ // window rather than the page. Surfaces that pin themselves to the viewport
5
+ // (e.g. AsyncSearch's narrow-viewport results takeover) are wrong inside one:
6
+ // the sheet already owns the screen, sits above them in the stacking order,
7
+ // and leaves them no room when it is pinned to the bottom edge.
8
+ export const inModalKey: InjectionKey<true> = Symbol('inModal')
package/src/main.ts ADDED
@@ -0,0 +1,78 @@
1
+ export { showToast, type ToastHandle, type ToastOptions, type ToastVariant } from './toast/showToast'
2
+ export { messages, configureUiMessages, type UiMessages } from './messages'
3
+ export { default as Loading } from './Loading.vue'
4
+ export { default as TabBar } from './TabBar.vue'
5
+ export type { TabDef } from './TabBar.vue'
6
+ export { default as Splash } from './Splash.vue'
7
+
8
+ // Form controls
9
+ export { default as TextField } from './TextField.vue'
10
+ export { default as Password } from './Password.vue'
11
+ export { default as Email } from './Email.vue'
12
+ export { default as Checkbox } from './Checkbox.vue'
13
+ export { default as CurrencyInput } from './CurrencyInput.vue'
14
+ export { default as Field } from './Field.vue'
15
+ export { default as FieldTip } from './FieldTip'
16
+ export { validationScopeKey } from './validationScope'
17
+
18
+ // Form submission state machine
19
+ export { default as FormC } from './Form.vue'
20
+ export { default as AsyncAction } from './AsyncAction.vue'
21
+ export { default as OptionSwitch } from './OptionSwitch.vue'
22
+ export type { OptionValue, OptionsArray, OptionsMap } from './OptionSwitch.vue'
23
+ export { default as SegmentedControl } from './SegmentedControl.vue'
24
+ export type { SegmentedOption } from './SegmentedControl.vue'
25
+
26
+ // Search
27
+ export { default as SearchField } from './SearchField.vue'
28
+ export { default as AsyncSearch } from './AsyncSearch.vue'
29
+ export type { SearchFunction } from './AsyncSearch.vue'
30
+ export type { SearchResultItem } from './AsyncSearchResults.vue'
31
+
32
+ // Overlay / navigation
33
+ export { default as Modal } from './Modal.vue'
34
+ export { default as ModalOnClick } from './ModalOnClick.vue'
35
+ export { default as MenuButton } from './MenuButton.vue'
36
+ export { default as SelectBox } from './SelectBox.vue'
37
+ export type { SelectBoxOption } from './SelectBox.vue'
38
+
39
+ // Media / collections
40
+ export { default as Gallery } from './Gallery.vue'
41
+ export type { GalleryItem } from './Gallery.vue'
42
+ export { default as ThumbnailGrid } from './ThumbnailGrid.vue'
43
+ export type { ThumbnailGridItem, ThumbnailGridLoader, ThumbnailGridLoaderParams } from './ThumbnailGrid.vue'
44
+ export { default as TreeView } from './TreeView.vue'
45
+ export type { TreeNode, TreeNodeId, TreeLoader } from './TreeView.vue'
46
+ export { default as Accordion } from './Accordion.vue'
47
+ export type { AccordionSection } from './Accordion.vue'
48
+ export { default as useSelection } from './useSelection'
49
+ export { default as RowSelect } from './RowSelect'
50
+ export { default as CircularProgress } from './CircularProgress.vue'
51
+ export { default as downsizeImage } from './downsizeImage'
52
+ export { default as Copy } from './Copy.vue'
53
+ export { copyTextToClipboard } from './copyTextToClipboard'
54
+
55
+ // Layout shell
56
+ export { default as TheWrap } from './TheWrap.vue'
57
+ export { default as BottomNav } from './BottomNav.vue'
58
+ export type { BottomNavTab } from './BottomNav.vue'
59
+
60
+ // Gestures
61
+ export { TOUCH_PAN_SLOP_PX } from './touchSlop'
62
+
63
+ // Small DOM conveniences
64
+ export const scrollToTop = (): void => window.scroll({ top: 0 })
65
+
66
+ export const focusIn = (el: string | HTMLElement | null, selector?: string): void => {
67
+ if (el) {
68
+ let input: Element | null = null
69
+ if (typeof el === 'string') {
70
+ input = document.getElementById(el)
71
+ } else if (selector) {
72
+ input = el.querySelector(selector)
73
+ }
74
+ if (input instanceof HTMLElement && document.activeElement !== input) {
75
+ input.focus()
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,39 @@
1
+ import { reactive } from 'vue'
2
+
3
+ // Every user-facing string in this package, overridable in one place. Apps
4
+ // with i18n call configureUiMessages at boot (and again on locale change).
5
+ const defaults = {
6
+ loading: 'Loading',
7
+ ok: 'OK',
8
+ cancel: 'Cancel',
9
+ close: 'Close',
10
+ clear: 'Clear',
11
+ tryAgain: 'Try again',
12
+ trying: 'Trying...',
13
+ // {seconds} is replaced with the countdown value at render time.
14
+ retryCountdown: 'Error. Retrying in {seconds}',
15
+ toHomePage: 'To the home page',
16
+ nothingFound: 'Nothing found',
17
+ searchResults: 'Search results',
18
+ search: 'Search',
19
+ create: 'Create',
20
+ refresh: 'Refresh',
21
+ delete: 'Delete',
22
+ deleting: 'Deleting...',
23
+ newTab: 'New tab',
24
+ copied: 'Copied',
25
+ clickToCopy: 'Click to copy',
26
+ invalidEmail: 'Not a valid email address',
27
+ selectOption: 'Select an option',
28
+ errorOther: 'Oops.. Sorry, something went wrong',
29
+ errorNetwork: 'Looks like a network connectivity issue',
30
+ errorNotFound: 'The requested page not found'
31
+ }
32
+
33
+ export type UiMessages = typeof defaults
34
+
35
+ export const messages: UiMessages = reactive({ ...defaults })
36
+
37
+ export const configureUiMessages = (overrides: Partial<UiMessages>): void => {
38
+ Object.assign(messages, overrides)
39
+ }
@@ -0,0 +1,181 @@
1
+ // Module-level modal stack: with nested modals open, Escape and browser
2
+ // history navigation (popstate) must close only the top-most modal, not all of
3
+ // them at once. Modals register their close handler on mount; the shared
4
+ // document/window listeners exist only while at least one modal is open and
5
+ // always act on the top of the stack.
6
+ //
7
+ // The stack also owns modal FOCUS CONFINEMENT (WCAG 2.4.3): while a modal is
8
+ // open, everything outside the top-most modal's root is `inert` (unfocusable,
9
+ // hidden from AT), stray focus is redirected back inside (defence-in-depth for
10
+ // engines with partial inert support, with first/last Tab wrapping), initial
11
+ // focus moves into the modal, and closing restores focus to the invoker.
12
+ // Centralised here — not per-modal — so only the top-most modal ever traps.
13
+ import { hasFinePointer } from '@greendrake/util'
14
+
15
+ type CloseHandler = () => void
16
+
17
+ // Where a modal wants focus when it opens: on the field it exists to fill, or
18
+ // on the dialog itself when its fields serve options within it rather than the
19
+ // modal's purpose. This states the modal's INTENT only — whether a field is
20
+ // actually worth focusing also depends on the device (see focusInitial).
21
+ export type InitialFocus = 'field' | 'dialog'
22
+
23
+ type ModalEntry = {
24
+ close: CloseHandler
25
+ root: HTMLElement | null
26
+ invoker: HTMLElement | null
27
+ }
28
+
29
+ const stack: ModalEntry[] = []
30
+ const top = (): ModalEntry | undefined => stack[stack.length - 1]
31
+
32
+ const closeTop = (): void => {
33
+ top()?.close()
34
+ }
35
+
36
+ const onKeyUp = (e: KeyboardEvent): void => {
37
+ if (e.key === 'Escape') {
38
+ closeTop()
39
+ }
40
+ }
41
+
42
+ // In an SPA the router drives route changes off popstate, so a modal left open
43
+ // by stale parent state would otherwise overlay the new page.
44
+ const onPopState = (): void => closeTop()
45
+
46
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
47
+
48
+ const focusables = (root: HTMLElement): HTMLElement[] => [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(n => n.getClientRects().length > 0)
49
+
50
+ // Fallback focus target when a modal holds no focusable content: its dialog
51
+ // container (carries tabindex="-1").
52
+ const container = (root: HTMLElement): HTMLElement | null => root.querySelector<HTMLElement>('[tabindex="-1"]')
53
+
54
+ // Initial focus prefers a form field (the reason most modals open) over
55
+ // chrome like the close button, which is first in DOM order. Two things veto
56
+ // that and hand focus to the dialog itself instead: the modal declaring its
57
+ // fields incidental (`initialFocus: 'dialog'`), and a device with no fine
58
+ // pointer. On touch, focusing a field summons the on-screen keyboard — half
59
+ // the viewport gone before the user has asked to type — which is a cost no
60
+ // modal's intent can be worth, so it is decided here rather than at each of
61
+ // them. Focus still moves INTO the modal either way: the trap, the dialog's
62
+ // announcement to assistive tech and the restore-on-close all depend on it
63
+ // (see registerModal), so the touch case redirects focus, never skips it.
64
+ //
65
+ // preventScroll on the open/close focus moves: they are structural, not
66
+ // navigation, and scrolling the page to reveal what the code just focused
67
+ // moves content the reader did not ask to move. The trap's Tab wrap
68
+ // (onFocusIn) deliberately does NOT prevent scroll — that move answers a
69
+ // keypress, and in a sheet with internal overflow the wrapped-to control may
70
+ // sit outside the visible part, where suppressing the reveal would land
71
+ // keyboard focus on something invisible.
72
+ const focusInitial = (root: HTMLElement, initialFocus: InitialFocus): void => {
73
+ if (initialFocus === 'dialog' || !hasFinePointer()) {
74
+ container(root)?.focus({ preventScroll: true })
75
+ return
76
+ }
77
+ const all = focusables(root)
78
+ const target = all.find(n => /^(input|textarea|select)$/i.test(n.tagName)) ?? all[0] ?? container(root)
79
+ target?.focus({ preventScroll: true })
80
+ }
81
+
82
+ // Background scroll lock, held while any modal is open. The page behind a
83
+ // modal has no business moving, and on iOS it otherwise does: focusing a field
84
+ // inside the sheet makes WebKit scroll the document to clear the keyboard, and
85
+ // since the sheet is fixed, all that moves is the page under it. Pinning the
86
+ // body is what actually leaves the document unscrollable — `overflow: hidden`
87
+ // propagates between html and body and stops nothing — so the keyboard scrolls
88
+ // the sheet's own overflow instead, which is the thing that should move.
89
+ let lockedAt: number | null = null
90
+ let lockedHref = ''
91
+ const lockScroll = (): void => {
92
+ lockedAt = window.scrollY
93
+ lockedHref = location.href
94
+ // Classic (space-taking) scrollbars: pinning the body leaves the document
95
+ // unscrollable, which drops its scrollbar and shifts the whole background
96
+ // by the gutter width. Hold that width as extra body padding for the
97
+ // lock's duration; overlay scrollbars measure 0 and change nothing.
98
+ const gutter = window.innerWidth - document.documentElement.clientWidth
99
+ Object.assign(document.body.style, {
100
+ position: 'fixed',
101
+ top: `-${lockedAt}px`,
102
+ left: '0',
103
+ right: '0',
104
+ ...(gutter > 0 && { paddingRight: `${parseFloat(getComputedStyle(document.body).paddingRight) + gutter}px` })
105
+ })
106
+ }
107
+ const unlockScroll = (): void => {
108
+ if (lockedAt === null) return
109
+ Object.assign(document.body.style, {
110
+ position: '',
111
+ top: '',
112
+ left: '',
113
+ right: '',
114
+ paddingRight: ''
115
+ })
116
+ // Restore the offset only where it still applies: a popstate can navigate
117
+ // and close the modal in one stroke (unregistration then runs after the
118
+ // route has changed), and the old page's offset imposed on the new page
119
+ // would override the browser's own scroll restoration.
120
+ if (location.href === lockedHref) window.scrollTo(0, lockedAt)
121
+ lockedAt = null
122
+ }
123
+
124
+ // Everything outside the top-most modal's root goes inert — including the
125
+ // roots of modals lower in the stack. Only attributes this module set are
126
+ // removed on recompute, so independently-inert content is left alone.
127
+ let inerted: Element[] = []
128
+ const applyInert = (): void => {
129
+ for (const n of inerted) n.removeAttribute('inert')
130
+ inerted = []
131
+ const root = top()?.root
132
+ if (!root) return
133
+ for (const child of document.body.children) {
134
+ if (child !== root && !child.contains(root) && !child.hasAttribute('inert')) {
135
+ child.setAttribute('inert', '')
136
+ inerted.push(child)
137
+ }
138
+ }
139
+ }
140
+
141
+ const onFocusIn = (e: FocusEvent): void => {
142
+ const root = top()?.root
143
+ if (!root || root.contains(e.target as Node)) return
144
+ const all = focusables(root)
145
+ if (!all.length) {
146
+ container(root)?.focus()
147
+ return
148
+ }
149
+ // Focus escaped: Shift-Tab off the first element wraps to the last;
150
+ // anything else (Tab off the last, programmatic strays) lands on the first.
151
+ const target = e.relatedTarget === all[0] ? all[all.length - 1] : all[0]
152
+ target.focus()
153
+ }
154
+
155
+ export const registerModal = (close: CloseHandler, root: HTMLElement | null = null, initialFocus: InitialFocus = 'field'): (() => void) => {
156
+ if (stack.length === 0) {
157
+ document.addEventListener('keyup', onKeyUp)
158
+ document.addEventListener('focusin', onFocusIn)
159
+ window.addEventListener('popstate', onPopState)
160
+ lockScroll()
161
+ }
162
+ const entry: ModalEntry = {
163
+ close,
164
+ root,
165
+ invoker: document.activeElement instanceof HTMLElement ? document.activeElement : null
166
+ }
167
+ stack.push(entry)
168
+ applyInert()
169
+ if (root) focusInitial(root, initialFocus)
170
+ return () => {
171
+ stack.splice(stack.indexOf(entry), 1)
172
+ if (stack.length === 0) {
173
+ document.removeEventListener('keyup', onKeyUp)
174
+ document.removeEventListener('focusin', onFocusIn)
175
+ window.removeEventListener('popstate', onPopState)
176
+ unlockScroll()
177
+ }
178
+ applyInert()
179
+ if (entry.invoker?.isConnected) entry.invoker.focus({ preventScroll: true })
180
+ }
181
+ }
@@ -0,0 +1,6 @@
1
+ // A stylesheet imported for effect resolves to nothing at the type level, and
2
+ // TypeScript 6 checks that it resolves at all (noUncheckedSideEffectImports).
3
+ // Declared here rather than left to the consumer's vite/client: this package
4
+ // ships source, so whatever compiles it — an app, another package, an outside
5
+ // project — must be told what the import means by the package that makes it.
6
+ declare module '*.scss'
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ import type { ToastVariant } from './showToast'
3
+
4
+ withDefaults(
5
+ defineProps<{
6
+ // Rendered as plain text; rich content arrives as a VNode via showToast.
7
+ text?: string
8
+ variant?: ToastVariant
9
+ }>(),
10
+ { text: undefined, variant: 'accent' }
11
+ )
12
+ </script>
13
+ <template>
14
+ <div class="Toast" :class="`Toast--${variant}`">
15
+ <template v-if="text">{{ text }}</template>
16
+ <slot v-else />
17
+ </div>
18
+ </template>
19
+ <style lang="scss">
20
+ @use '@greendrake/scss-kit' as *;
21
+
22
+ // The shared layer showToast mounts every toast into — one per document, the
23
+ // toasts stacked down from the top in the order they were raised.
24
+ .ToastLayer {
25
+ position: fixed;
26
+ // Below the status bar and cutout, not over them. The layer is fixed, so
27
+ // it sits outside the padding body spends on the same inset — without this
28
+ // it would render across the clock and battery on a native shell. The
29
+ // token is 0 in browsers, leaving the gap as it was.
30
+ top: calc(var(--safe-area-inset-top, 0px) + 10px);
31
+ left: 0;
32
+ right: 0;
33
+ display: flex;
34
+ flex-direction: column;
35
+ align-items: center;
36
+ gap: var(--space-2);
37
+ pointer-events: none;
38
+ z-index: z('toast');
39
+ }
40
+
41
+ .Toast {
42
+ pointer-events: auto;
43
+ max-width: min(92vw, 480px);
44
+ padding: var(--space-3) var(--space-4);
45
+ border-radius: 8px;
46
+ color: var(--toast-color, #fff);
47
+ box-shadow: 0 4px 14px rgb(0 0 0 / 35%);
48
+ a {
49
+ color: var(--toast-color, #fff);
50
+ }
51
+ }
52
+
53
+ // An ordinary toast reports something that worked; the warning variant is the
54
+ // failure surface, and reads as one at a glance rather than only in its words.
55
+ // `--toast-background` still overrides both, for an app that wants its own.
56
+ .Toast--accent {
57
+ background-color: var(--toast-background, var(--accent-color));
58
+ }
59
+
60
+ .Toast--warning {
61
+ background-color: var(--toast-background, var(--warning-color));
62
+ }
63
+
64
+ .BlockingOverlay {
65
+ position: fixed;
66
+ inset: 0;
67
+ z-index: z('modal');
68
+ background-color: rgb(0 0 0 / 25%);
69
+ }
70
+ </style>
@@ -0,0 +1,81 @@
1
+ import { h, render, type VNode } from 'vue'
2
+ import ToastShell from './ToastShell.vue'
3
+
4
+ export interface ToastHandle {
5
+ destroy: () => void
6
+ }
7
+
8
+ // What the toast is saying, which decides its colour: `accent` for the ordinary
9
+ // "that worked" report, `warning` for a failure.
10
+ export type ToastVariant = 'accent' | 'warning'
11
+
12
+ export interface ToastOptions {
13
+ content: string | VNode
14
+ // Auto-dismiss on the next user interaction (key press / pointer down)
15
+ // after the toast appears. Default true; blocking error toasts opt out.
16
+ hideOnClick?: boolean
17
+ // Defaults to `accent`: most toasts confirm something, and a failure says
18
+ // so explicitly rather than every caller restating the ordinary case.
19
+ variant?: ToastVariant
20
+ onHidden?: () => void
21
+ }
22
+
23
+ // The one fixed top-center layer every toast renders into, so toasts raised
24
+ // together stack in the order shown rather than each sitting over the last.
25
+ // Created on first use and kept: it is pointer-transparent, and empty it
26
+ // costs nothing.
27
+ let layer: HTMLElement | null = null
28
+ const toastLayer = (): HTMLElement => {
29
+ if (!layer) {
30
+ layer = document.createElement('div')
31
+ layer.className = 'ToastLayer'
32
+ document.body.appendChild(layer)
33
+ }
34
+ return layer
35
+ }
36
+
37
+ // Imperative top-center toast. Renders without an app context, so it is
38
+ // callable from stores and plain modules; interactive content arrives as a
39
+ // VNode with its handlers already bound.
40
+ export const showToast = (options: ToastOptions): ToastHandle => {
41
+ const host = document.createElement('div')
42
+ toastLayer().appendChild(host)
43
+ const isText = typeof options.content === 'string'
44
+ const shellProps = { variant: options.variant ?? 'accent' }
45
+ const vnode = isText ? h(ToastShell, { ...shellProps, text: options.content as string }) : h(ToastShell, shellProps, { default: () => options.content as VNode })
46
+ render(vnode, host)
47
+
48
+ const destroy = (): void => {
49
+ detachDismiss()
50
+ render(null, host)
51
+ host.remove()
52
+ options.onHidden?.()
53
+ }
54
+
55
+ let detachDismiss = (): void => {}
56
+ if (options.hideOnClick !== false) {
57
+ // Pointer-downs inside ANY toast don't dismiss: destroying at
58
+ // pointerdown would unmount interactive content (e.g. a link) before
59
+ // the browser ever synthesizes its click — and destroying a toast
60
+ // stacked above the one being pressed would shift that one out from
61
+ // under the pointer, so the click lands elsewhere. Interacting with a
62
+ // toast is not the interaction that clears the stack.
63
+ const onInteraction = (e: Event): void => {
64
+ if (e.target instanceof Node && toastLayer().contains(e.target)) return
65
+ destroy()
66
+ }
67
+ // Attach on the next frame so the interaction that triggered the toast
68
+ // cannot dismiss it in the same event cycle.
69
+ const raf = requestAnimationFrame(() => {
70
+ document.addEventListener('keydown', onInteraction)
71
+ document.addEventListener('pointerdown', onInteraction)
72
+ })
73
+ detachDismiss = () => {
74
+ cancelAnimationFrame(raf)
75
+ document.removeEventListener('keydown', onInteraction)
76
+ document.removeEventListener('pointerdown', onInteraction)
77
+ }
78
+ }
79
+
80
+ return { destroy }
81
+ }
@@ -0,0 +1,6 @@
1
+ // Distance a touch/pen pointer must travel before the gesture counts as a
2
+ // deliberate directional move rather than a tap or start-of-gesture jitter —
3
+ // mirrors typical platform touch slop. Its own module so the components that
4
+ // arbitrate gestures (the sheet drag in Modal, the pan in InfiniteScrollTable)
5
+ // share one value without importing each other.
6
+ export const TOUCH_PAN_SLOP_PX = 10
@@ -0,0 +1,99 @@
1
+ import { shallowReactive, type Ref, type ComputedRef } from 'vue'
2
+
3
+ // Flat, index-addressable multi-selection over an ordered item list: single
4
+ // (replace), ctrl-toggle, shift-range from an anchor, keyboard cursor moves,
5
+ // select-all. The selected Set holds item ids read from items[i][idField].
6
+ export default function useSelection<Id extends string | number>(items: Ref<Record<string, unknown>[]> | ComputedRef<Record<string, unknown>[]>, idField: string) {
7
+ const selected: Set<Id> = shallowReactive(new Set())
8
+ let anchor: Id | null = null
9
+ let cursor: Id | null = null
10
+
11
+ function getId(index: number): Id {
12
+ return items.value[index][idField] as Id
13
+ }
14
+
15
+ function indexOf(id: Id): number {
16
+ return items.value.findIndex(item => (item[idField] as Id) === id)
17
+ }
18
+
19
+ function addRange(fromId: Id, toId: Id): void {
20
+ const a = indexOf(fromId)
21
+ const b = indexOf(toId)
22
+ if (a === -1 || b === -1) return
23
+ const [lo, hi] = a <= b ? [a, b] : [b, a]
24
+ for (let i = lo; i <= hi; i++) selected.add(getId(i))
25
+ }
26
+
27
+ function toggle(id: Id): void {
28
+ if (selected.has(id)) selected.delete(id)
29
+ else selected.add(id)
30
+ anchor = id
31
+ cursor = id
32
+ }
33
+
34
+ function replace(id: Id): void {
35
+ selected.clear()
36
+ selected.add(id)
37
+ anchor = id
38
+ cursor = id
39
+ }
40
+
41
+ function rangeTo(id: Id): void {
42
+ if (anchor === null) {
43
+ toggle(id)
44
+ return
45
+ }
46
+ selected.clear()
47
+ addRange(anchor, id)
48
+ cursor = id
49
+ }
50
+
51
+ /**
52
+ * Move cursor up or down.
53
+ * If extend is true (shift held), add to selection.
54
+ * If extend is false, replace selection with single item.
55
+ * Returns the new cursor index (useful for scrolling into view), or -1 if no items.
56
+ */
57
+ function moveCursor(direction: 'up' | 'down', extend: boolean): number {
58
+ if (!items.value.length) return -1
59
+ const max = items.value.length - 1
60
+ const curIdx = cursor !== null ? indexOf(cursor) : -1
61
+ const next = direction === 'up' ? Math.max(0, curIdx <= 0 ? 0 : curIdx - 1) : Math.min(max, curIdx === -1 ? 0 : curIdx + 1)
62
+ const nextId = getId(next)
63
+
64
+ if (extend) {
65
+ selected.add(nextId)
66
+ cursor = nextId
67
+ if (anchor === null) anchor = nextId
68
+ } else {
69
+ selected.clear()
70
+ selected.add(nextId)
71
+ anchor = nextId
72
+ cursor = nextId
73
+ }
74
+ return next
75
+ }
76
+
77
+ function selectAll(): void {
78
+ for (let i = 0; i < items.value.length; i++) selected.add(getId(i))
79
+ }
80
+
81
+ function clear(): void {
82
+ selected.clear()
83
+ anchor = null
84
+ cursor = null
85
+ }
86
+
87
+ return {
88
+ selected,
89
+ getId,
90
+ indexOf,
91
+ addRange,
92
+ toggle,
93
+ replace,
94
+ rangeTo,
95
+ moveCursor,
96
+ selectAll,
97
+ clear
98
+ }
99
+ }
@@ -0,0 +1,6 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+
3
+ // Form components provide a template ref to their root element under this key
4
+ // so descendant TextFields can suppress blur-time validation when focus leaves
5
+ // the form (e.g. user clicks Sign In in the header without completing the form).
6
+ export const validationScopeKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('validationScope')