@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.
- package/LICENSE +21 -0
- package/README.md +327 -0
- package/package.json +52 -0
- package/src/Accordion.vue +84 -0
- package/src/AsyncAction.vue +66 -0
- package/src/AsyncSearch.vue +262 -0
- package/src/AsyncSearchResults.vue +74 -0
- package/src/BottomNav.vue +125 -0
- package/src/Checkbox.vue +112 -0
- package/src/CircularProgress.vue +57 -0
- package/src/Copy.vue +51 -0
- package/src/CurrencyInput.vue +143 -0
- package/src/Email.vue +17 -0
- package/src/Field.vue +54 -0
- package/src/FieldTip.scss +76 -0
- package/src/FieldTip.ts +57 -0
- package/src/FixAndroidTyping.ts +13 -0
- package/src/Form.ts +124 -0
- package/src/Form.vue +78 -0
- package/src/Gallery.vue +174 -0
- package/src/Loading.vue +32 -0
- package/src/MenuButton.vue +95 -0
- package/src/Modal.vue +365 -0
- package/src/ModalOnClick.vue +73 -0
- package/src/OptionSwitch.vue +110 -0
- package/src/Password.vue +85 -0
- package/src/RowSelect.ts +211 -0
- package/src/SearchField.vue +124 -0
- package/src/SegmentedControl.vue +85 -0
- package/src/SelectBox.vue +468 -0
- package/src/Splash.vue +36 -0
- package/src/TabBar.vue +158 -0
- package/src/TextField.vue +116 -0
- package/src/TheWrap.vue +30 -0
- package/src/ThumbnailGrid.vue +166 -0
- package/src/TreeView.vue +308 -0
- package/src/_shared.scss +22 -0
- package/src/copyTextToClipboard.ts +3 -0
- package/src/downsizeImage.ts +78 -0
- package/src/inModal.ts +8 -0
- package/src/main.ts +78 -0
- package/src/messages.ts +39 -0
- package/src/modalStack.ts +181 -0
- package/src/styles.d.ts +6 -0
- package/src/toast/ToastShell.vue +70 -0
- package/src/toast/showToast.ts +81 -0
- package/src/touchSlop.ts +6 -0
- package/src/useSelection.ts +99 -0
- 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
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -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
|
+
}
|
package/src/styles.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/touchSlop.ts
ADDED
|
@@ -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')
|