@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
package/src/Gallery.vue
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface GalleryItem {
|
|
3
|
+
url: string
|
|
4
|
+
alt?: string
|
|
5
|
+
// Optional note rendered beneath the image (e.g. a shareable link under a
|
|
6
|
+
// QR code). Travels with the item, so it follows navigation in a multi-image
|
|
7
|
+
// gallery.
|
|
8
|
+
caption?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SWIPE_THRESHOLD = 50
|
|
12
|
+
</script>
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
import { onMounted, onUnmounted, ref } from 'vue'
|
|
15
|
+
import Modal from './Modal.vue'
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
items: GalleryItem[]
|
|
20
|
+
startIndex?: number
|
|
21
|
+
}>(),
|
|
22
|
+
{ startIndex: 0 }
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{
|
|
26
|
+
cancel: []
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const currentIndex = ref(props.startIndex)
|
|
30
|
+
const loading = ref(true)
|
|
31
|
+
|
|
32
|
+
function prev() {
|
|
33
|
+
if (currentIndex.value > 0) {
|
|
34
|
+
loading.value = true
|
|
35
|
+
currentIndex.value--
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function next() {
|
|
40
|
+
if (currentIndex.value < props.items.length - 1) {
|
|
41
|
+
loading.value = true
|
|
42
|
+
currentIndex.value++
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
47
|
+
if (e.key === 'ArrowLeft') prev()
|
|
48
|
+
else if (e.key === 'ArrowRight') next()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let touchStartX = 0
|
|
52
|
+
let touchStartY = 0
|
|
53
|
+
let touchStartTarget: EventTarget | null = null
|
|
54
|
+
let multiTouch = false
|
|
55
|
+
|
|
56
|
+
function onTouchStart(e: TouchEvent) {
|
|
57
|
+
// A second finger turns the gesture into a pinch/zoom: hand it entirely to the
|
|
58
|
+
// browser and stop tracking, so the following touchend neither navigates nor closes.
|
|
59
|
+
if (e.touches.length > 1) {
|
|
60
|
+
multiTouch = true
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
touchStartX = e.touches[0].clientX
|
|
64
|
+
touchStartY = e.touches[0].clientY
|
|
65
|
+
touchStartTarget = e.target
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function onTouchEnd(e: TouchEvent) {
|
|
69
|
+
if (multiTouch) {
|
|
70
|
+
// Ignore every touchend of a pinch/zoom until its last finger lifts.
|
|
71
|
+
if (e.touches.length === 0) multiTouch = false
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const dx = e.changedTouches[0].clientX - touchStartX
|
|
75
|
+
const dy = e.changedTouches[0].clientY - touchStartY
|
|
76
|
+
const isSwipe = Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)
|
|
77
|
+
if (isSwipe) {
|
|
78
|
+
if (dx < 0) {
|
|
79
|
+
if (currentIndex.value < props.items.length - 1) next()
|
|
80
|
+
else emit('cancel')
|
|
81
|
+
} else {
|
|
82
|
+
if (currentIndex.value > 0) prev()
|
|
83
|
+
else emit('cancel')
|
|
84
|
+
}
|
|
85
|
+
} else if (Math.abs(dx) < SWIPE_THRESHOLD && Math.abs(dy) < SWIPE_THRESHOLD) {
|
|
86
|
+
const target = touchStartTarget as HTMLElement | null
|
|
87
|
+
if (target?.tagName !== 'IMG' && !target?.closest('.Gallery__nav')) {
|
|
88
|
+
emit('cancel')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const modal = ref<InstanceType<typeof Modal> | null>(null)
|
|
94
|
+
onMounted(() => {
|
|
95
|
+
document.addEventListener('keyup', onKeyUp)
|
|
96
|
+
// Touch listeners go on the modal's root element (its own exposure point);
|
|
97
|
+
// they are disposed of together with that element.
|
|
98
|
+
const el = modal.value!.rootEl!
|
|
99
|
+
el.addEventListener('touchstart', onTouchStart)
|
|
100
|
+
el.addEventListener('touchend', onTouchEnd)
|
|
101
|
+
})
|
|
102
|
+
onUnmounted(() => document.removeEventListener('keyup', onKeyUp))
|
|
103
|
+
</script>
|
|
104
|
+
<template>
|
|
105
|
+
<Modal ref="modal" class="Gallery Modal--expanded" closable :buttons="false" :main-class="{ spinner: loading }" @cancel="emit('cancel')">
|
|
106
|
+
<img :src="items[currentIndex].url" :alt="items[currentIndex].alt" @load="loading = false" @click="emit('cancel')" />
|
|
107
|
+
<p v-if="items[currentIndex].caption" class="Gallery__caption">{{ items[currentIndex].caption }}</p>
|
|
108
|
+
<button v-if="currentIndex > 0" class="Gallery__nav Gallery__nav--prev" @click="prev" />
|
|
109
|
+
<button v-if="currentIndex < items.length - 1" class="Gallery__nav Gallery__nav--next" @click="next" />
|
|
110
|
+
</Modal>
|
|
111
|
+
</template>
|
|
112
|
+
<style lang="scss">
|
|
113
|
+
@use '@greendrake/scss-kit' as *;
|
|
114
|
+
.Gallery {
|
|
115
|
+
.Modal__toolbar-btn {
|
|
116
|
+
width: 18px;
|
|
117
|
+
height: 18px;
|
|
118
|
+
opacity: 0.8;
|
|
119
|
+
&:hover {
|
|
120
|
+
opacity: 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
.Modal__main {
|
|
124
|
+
overflow: auto;
|
|
125
|
+
display: flex;
|
|
126
|
+
flex-direction: column;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: center;
|
|
129
|
+
gap: var(--space-3);
|
|
130
|
+
img {
|
|
131
|
+
display: block;
|
|
132
|
+
// min-height: 0 lets the image shrink within the column so an
|
|
133
|
+
// optional caption below it always stays visible.
|
|
134
|
+
min-height: 0;
|
|
135
|
+
max-width: 100%;
|
|
136
|
+
max-height: 100%;
|
|
137
|
+
object-fit: contain;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
&__caption {
|
|
141
|
+
flex: 0 0 auto;
|
|
142
|
+
margin: 0;
|
|
143
|
+
max-width: 100%;
|
|
144
|
+
text-align: center;
|
|
145
|
+
// URLs and other unbroken strings must wrap rather than overflow.
|
|
146
|
+
word-break: break-word;
|
|
147
|
+
opacity: 0.85;
|
|
148
|
+
}
|
|
149
|
+
&__nav {
|
|
150
|
+
@include icon;
|
|
151
|
+
position: absolute;
|
|
152
|
+
top: 50%;
|
|
153
|
+
transform: translateY(-50%);
|
|
154
|
+
width: 36px;
|
|
155
|
+
height: 36px;
|
|
156
|
+
cursor: default;
|
|
157
|
+
padding: 0;
|
|
158
|
+
background-color: var(--font-color);
|
|
159
|
+
border-radius: 50%;
|
|
160
|
+
opacity: 0.8;
|
|
161
|
+
&:hover {
|
|
162
|
+
opacity: 1;
|
|
163
|
+
}
|
|
164
|
+
&--prev {
|
|
165
|
+
left: 8px;
|
|
166
|
+
@include i('chevron-left');
|
|
167
|
+
}
|
|
168
|
+
&--next {
|
|
169
|
+
right: 8px;
|
|
170
|
+
@include i('chevron-right');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
</style>
|
package/src/Loading.vue
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { messages } from './messages'
|
|
3
|
+
</script>
|
|
4
|
+
<template>
|
|
5
|
+
<div class="Loading">{{ messages.loading }} <span>.</span><span>.</span><span>.</span></div>
|
|
6
|
+
</template>
|
|
7
|
+
<style lang="scss" scoped>
|
|
8
|
+
@keyframes LoadingDots {
|
|
9
|
+
0% {
|
|
10
|
+
opacity: 1;
|
|
11
|
+
}
|
|
12
|
+
50% {
|
|
13
|
+
opacity: 0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
div {
|
|
17
|
+
white-space: nowrap;
|
|
18
|
+
span {
|
|
19
|
+
letter-spacing: 3px;
|
|
20
|
+
animation: LoadingDots 1s infinite linear;
|
|
21
|
+
&:nth-child(1) {
|
|
22
|
+
animation-delay: 0s;
|
|
23
|
+
}
|
|
24
|
+
&:nth-child(2) {
|
|
25
|
+
animation-delay: 0.2s;
|
|
26
|
+
}
|
|
27
|
+
&:nth-child(3) {
|
|
28
|
+
animation-delay: 0.4s;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
</style>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Placed by offsets rather than by a transform. A transformed element is the
|
|
3
|
+
// containing block for every position:fixed descendant, and a menu row may
|
|
4
|
+
// carry a control that places its own layer against the viewport (SelectBox's
|
|
5
|
+
// dropdown) — under the default translate that layer lands offset by the
|
|
6
|
+
// popper's own displacement, which on a bottom-anchored menu is most of the
|
|
7
|
+
// screen. Nothing else about the placement changes: Popper writes the same
|
|
8
|
+
// coordinates as top/left.
|
|
9
|
+
const popperOptions = { modifiers: [{ name: 'computeStyles', options: { gpuAcceleration: false } }] }
|
|
10
|
+
|
|
11
|
+
const appendTo = () => document.body
|
|
12
|
+
</script>
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
import { computed } from 'vue'
|
|
15
|
+
import { Tippy } from 'vue-tippy'
|
|
16
|
+
|
|
17
|
+
interface TippyInstance {
|
|
18
|
+
popper: HTMLElement
|
|
19
|
+
reference: Element
|
|
20
|
+
hide: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const props = withDefaults(
|
|
24
|
+
defineProps<{
|
|
25
|
+
// Class(es) on the menu's content wrapper (`.Menubutton-Menu`); scope
|
|
26
|
+
// per-instance *menu-item* styling here.
|
|
27
|
+
menuClass?: string
|
|
28
|
+
// Class(es) on the tippy box itself (the themed `.tippy-box`); scope
|
|
29
|
+
// per-instance *box-level* styling here (overriding the shared HMenu
|
|
30
|
+
// padding/min-width/background). Applied once on create — treat as static.
|
|
31
|
+
boxClass?: string
|
|
32
|
+
offset?: [number, number]
|
|
33
|
+
// Pin the popper's width to the trigger element's measured width on
|
|
34
|
+
// each open. Useful when the trigger is a styled box (e.g. AuthButton)
|
|
35
|
+
// and the menu should sit flush under it.
|
|
36
|
+
matchTriggerWidth?: boolean
|
|
37
|
+
}>(),
|
|
38
|
+
{
|
|
39
|
+
menuClass: '',
|
|
40
|
+
boxClass: '',
|
|
41
|
+
offset: () => [0, -1] as [number, number],
|
|
42
|
+
matchTriggerWidth: false
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const menuClasses = computed(() => ['Menubutton-Menu', props.menuClass].filter(Boolean))
|
|
47
|
+
|
|
48
|
+
// The themed box element — tippy nests it as the popper's only child.
|
|
49
|
+
const getBox = (instance: TippyInstance) => instance.popper.firstElementChild as HTMLElement | null
|
|
50
|
+
|
|
51
|
+
const onCreate = (instance: TippyInstance) => {
|
|
52
|
+
instance.popper.addEventListener('click', () => instance.hide())
|
|
53
|
+
if (props.boxClass) getBox(instance)?.classList.add(...props.boxClass.split(' ').filter(Boolean))
|
|
54
|
+
}
|
|
55
|
+
const onShow = (instance: TippyInstance) => {
|
|
56
|
+
if (!props.matchTriggerWidth) return
|
|
57
|
+
// The HMenu theme applies min-width: 100px to tippy-box, which would
|
|
58
|
+
// override a narrower trigger. Set both width and min-width on the box so
|
|
59
|
+
// the override holds.
|
|
60
|
+
const box = getBox(instance)
|
|
61
|
+
if (!box) return
|
|
62
|
+
const w = `${(instance.reference as HTMLElement).offsetWidth}px`
|
|
63
|
+
box.style.width = w
|
|
64
|
+
box.style.minWidth = w
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
<template>
|
|
68
|
+
<Tippy :append-to="appendTo" :aria="{ content: null, expanded: false }" :arrow="false" theme="HMenu" :interactive="true" :offset="props.offset" :popper-options="popperOptions" placement="bottom-end" trigger="mouseenter click" :on-create="onCreate" :on-show="onShow">
|
|
69
|
+
<slot name="main" />
|
|
70
|
+
<template #content>
|
|
71
|
+
<div :class="menuClasses">
|
|
72
|
+
<slot name="menuitems" />
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
75
|
+
</Tippy>
|
|
76
|
+
</template>
|
|
77
|
+
<style lang="scss">
|
|
78
|
+
.Menubutton-Menu {
|
|
79
|
+
a {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
.tippy-box[data-theme~='HMenu'] {
|
|
85
|
+
opacity: 1;
|
|
86
|
+
border-radius: 0;
|
|
87
|
+
padding: 0;
|
|
88
|
+
transition: none;
|
|
89
|
+
background: none;
|
|
90
|
+
.tippy-content {
|
|
91
|
+
padding: 0;
|
|
92
|
+
}
|
|
93
|
+
min-width: 100px;
|
|
94
|
+
}
|
|
95
|
+
</style>
|
package/src/Modal.vue
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div ref="rootEl" class="Modal" :class="{ 'Modal--expanded': expanded }" v-bind="$attrs">
|
|
4
|
+
<!-- tabindex="-1" is the stack's fallback focus target when the modal
|
|
5
|
+
holds no focusable content — see modalStack focus confinement. -->
|
|
6
|
+
<div ref="windowEl" class="Modal__window" role="dialog" aria-modal="true" :aria-label="title" tabindex="-1" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="resetDrag">
|
|
7
|
+
<!-- Titled header with an explicit close — the sheet idiom
|
|
8
|
+
(F14): every modal states what it is and how to leave. -->
|
|
9
|
+
<div v-if="title" class="Modal__header">
|
|
10
|
+
<span class="Modal__title">{{ title }}</span>
|
|
11
|
+
<button class="Modal__toolbar-btn close" :aria-label="messages.close" @click="onCancel" />
|
|
12
|
+
</div>
|
|
13
|
+
<div v-if="closable || expandable" class="Modal__toolbar">
|
|
14
|
+
<button v-if="expandable" class="Modal__toolbar-btn" :class="expanded ? 'collapse' : 'expand'" @click="expanded = !expanded" />
|
|
15
|
+
<button v-if="closable" class="Modal__toolbar-btn close" @click="onCancel" />
|
|
16
|
+
</div>
|
|
17
|
+
<div class="Modal__main" :class="mainClass">
|
|
18
|
+
<slot />
|
|
19
|
+
</div>
|
|
20
|
+
<div v-if="buttons" class="Modal__buttons">
|
|
21
|
+
<slot name="buttons">
|
|
22
|
+
<button v-if="!hideCancel" class="cancel" @click="onCancel">
|
|
23
|
+
<slot name="cancel">{{ cancelLabel ?? messages.cancel }}</slot>
|
|
24
|
+
</button>
|
|
25
|
+
<button v-if="!hideOk" class="ok" :disabled="okDisabled" @click="onOK">
|
|
26
|
+
<slot name="ok">{{ okLabel ?? messages.ok }}</slot>
|
|
27
|
+
</button>
|
|
28
|
+
</slot>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</Teleport>
|
|
33
|
+
</template>
|
|
34
|
+
<script lang="ts">
|
|
35
|
+
// Drag-to-dismiss, the gesture half of the bottom-sheet idiom the narrow
|
|
36
|
+
// presentation adopts below. A downward drag past either bound closes the
|
|
37
|
+
// sheet: a quarter of its own height, or — however short — a parting flick
|
|
38
|
+
// faster than this, which is how a decisive flick reads as dismissal rather
|
|
39
|
+
// than as a nudge that springs back.
|
|
40
|
+
const DISMISS_TRAVEL = 0.25
|
|
41
|
+
const DISMISS_VELOCITY = 0.5 // px/ms
|
|
42
|
+
// A drag beginning inside a control that reads drags itself — caret placement,
|
|
43
|
+
// text selection — belongs to that control, not to the sheet.
|
|
44
|
+
const OWN_DRAG = 'input, textarea, select, [contenteditable]'
|
|
45
|
+
|
|
46
|
+
// A touch the sheet is following, or still deciding on: where it started, where
|
|
47
|
+
// it last was and when (the parting velocity comes from that segment), how far
|
|
48
|
+
// the sheet currently stands from home, and whether the touch has yet resolved
|
|
49
|
+
// into a drag this sheet owns rather than a scroll the content owns.
|
|
50
|
+
interface SheetDrag {
|
|
51
|
+
startY: number
|
|
52
|
+
lastY: number
|
|
53
|
+
lastAt: number
|
|
54
|
+
by: number
|
|
55
|
+
velocity: number
|
|
56
|
+
owned: boolean
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import { onMounted, onUnmounted, provide, ref } from 'vue'
|
|
61
|
+
import { mediaBelow } from '@greendrake/scss-kit/breakpoints'
|
|
62
|
+
import { TOUCH_PAN_SLOP_PX } from './touchSlop'
|
|
63
|
+
import { registerModal, type InitialFocus } from './modalStack'
|
|
64
|
+
import { inModalKey } from './inModal'
|
|
65
|
+
import { messages } from './messages'
|
|
66
|
+
|
|
67
|
+
defineOptions({ inheritAttrs: false })
|
|
68
|
+
|
|
69
|
+
provide(inModalKey, true)
|
|
70
|
+
|
|
71
|
+
const props = withDefaults(
|
|
72
|
+
defineProps<{
|
|
73
|
+
// Renders a titled header with an explicit close affordance.
|
|
74
|
+
title?: string
|
|
75
|
+
closable?: boolean
|
|
76
|
+
expandable?: boolean
|
|
77
|
+
buttons?: boolean
|
|
78
|
+
mainClass?: string | Record<string, boolean>
|
|
79
|
+
okLabel?: string
|
|
80
|
+
cancelLabel?: string
|
|
81
|
+
okDisabled?: boolean
|
|
82
|
+
hideOk?: boolean
|
|
83
|
+
hideCancel?: boolean
|
|
84
|
+
// 'dialog' for a modal whose fields belong to options inside it rather
|
|
85
|
+
// than to the modal's purpose: opening then leaves them untouched
|
|
86
|
+
// instead of summoning a soft keyboard over the choice being made.
|
|
87
|
+
initialFocus?: InitialFocus
|
|
88
|
+
}>(),
|
|
89
|
+
{
|
|
90
|
+
closable: false,
|
|
91
|
+
expandable: false,
|
|
92
|
+
initialFocus: 'field',
|
|
93
|
+
buttons: true,
|
|
94
|
+
okDisabled: false,
|
|
95
|
+
hideOk: false,
|
|
96
|
+
hideCancel: false
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const emit = defineEmits<{
|
|
101
|
+
cancel: []
|
|
102
|
+
ok: []
|
|
103
|
+
}>()
|
|
104
|
+
|
|
105
|
+
const expanded = ref(false)
|
|
106
|
+
|
|
107
|
+
const onCancel = () => emit('cancel')
|
|
108
|
+
const onOK = () => emit('ok')
|
|
109
|
+
|
|
110
|
+
// Escape/popstate close the top-most open modal only, and the stack confines
|
|
111
|
+
// focus to it (background inert, Tab wrap, focus restore) — see modalStack.
|
|
112
|
+
let unregister: () => void
|
|
113
|
+
onMounted(() => {
|
|
114
|
+
unregister = registerModal(onCancel, rootEl.value, props.initialFocus)
|
|
115
|
+
})
|
|
116
|
+
onUnmounted(() => unregister())
|
|
117
|
+
|
|
118
|
+
// The teleported root element, for hosts that need to attach native listeners
|
|
119
|
+
// to the modal surface (e.g. Gallery's swipe handling).
|
|
120
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
121
|
+
defineExpose({ rootEl })
|
|
122
|
+
|
|
123
|
+
// The sheet under the finger. Null whenever no drag owns the current touch —
|
|
124
|
+
// which is every touch outside the sheet presentation, and every one the checks
|
|
125
|
+
// in onTouchStart hand back.
|
|
126
|
+
const windowEl = ref<HTMLElement | null>(null)
|
|
127
|
+
let drag: SheetDrag | null = null
|
|
128
|
+
|
|
129
|
+
const resetDrag = (): void => {
|
|
130
|
+
drag = null
|
|
131
|
+
// Clearing the inline transition hands the element back to the stylesheet's,
|
|
132
|
+
// which is what springs it home from wherever the finger left it.
|
|
133
|
+
Object.assign(windowEl.value!.style, { transition: '', transform: '' })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const onTouchStart = (e: TouchEvent): void => {
|
|
137
|
+
resetDrag()
|
|
138
|
+
// A second finger makes this a pinch, which is the content's gesture.
|
|
139
|
+
if (e.touches.length > 1) return
|
|
140
|
+
// Only the narrow presentation is a sheet; a centred dialog has no edge to
|
|
141
|
+
// be pushed off (the respond-below block below is the same boundary).
|
|
142
|
+
if (!matchMedia(mediaBelow('narrow')).matches) return
|
|
143
|
+
const target = e.target as HTMLElement
|
|
144
|
+
if (target.closest(OWN_DRAG)) return
|
|
145
|
+
// The browser's own scroll chaining, in the one direction that matters: the
|
|
146
|
+
// drag is the sheet's only if nothing between the touch and the sheet has
|
|
147
|
+
// room to scroll back up first. Anything unscrollable sits at 0 and passes.
|
|
148
|
+
for (let n: HTMLElement | null = target; n && n !== windowEl.value!.parentElement; n = n.parentElement) {
|
|
149
|
+
if (n.scrollTop > 0) return
|
|
150
|
+
}
|
|
151
|
+
const { clientY } = e.touches[0]
|
|
152
|
+
drag = {
|
|
153
|
+
startY: clientY,
|
|
154
|
+
lastY: clientY,
|
|
155
|
+
lastAt: e.timeStamp,
|
|
156
|
+
by: 0,
|
|
157
|
+
velocity: 0,
|
|
158
|
+
owned: false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const onTouchMove = (e: TouchEvent): void => {
|
|
163
|
+
if (!drag) return
|
|
164
|
+
const y = e.touches[0].clientY
|
|
165
|
+
const by = y - drag.startY
|
|
166
|
+
if (!drag.owned) {
|
|
167
|
+
// Which gesture this is only becomes readable once the touch has
|
|
168
|
+
// travelled the platform's slop: jitter at the start of a scroll would
|
|
169
|
+
// otherwise decide it.
|
|
170
|
+
if (Math.abs(by) < TOUCH_PAN_SLOP_PX) return
|
|
171
|
+
// Upward — the content is what the user means to move, so hand the
|
|
172
|
+
// whole touch back and let it scroll untouched.
|
|
173
|
+
if (by < 0) return resetDrag()
|
|
174
|
+
drag.owned = true
|
|
175
|
+
}
|
|
176
|
+
// Owning the drag means owning what the browser would otherwise do with it:
|
|
177
|
+
// rubber-band the sheet's own overscroll.
|
|
178
|
+
e.preventDefault()
|
|
179
|
+
const elapsed = e.timeStamp - drag.lastAt
|
|
180
|
+
// Velocity from the latest segment only — a slow drag that ends in a flick
|
|
181
|
+
// is a dismissal, and an average over the whole gesture would miss it.
|
|
182
|
+
if (elapsed > 0) drag.velocity = (y - drag.lastY) / elapsed
|
|
183
|
+
drag.lastY = y
|
|
184
|
+
drag.lastAt = e.timeStamp
|
|
185
|
+
// Dragging back past the start does not lift the sheet above its resting
|
|
186
|
+
// edge; it just returns it there.
|
|
187
|
+
drag.by = Math.max(0, by)
|
|
188
|
+
Object.assign(windowEl.value!.style, { transition: 'none', transform: `translateY(${drag.by}px)` })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const onTouchEnd = (): void => {
|
|
192
|
+
if (!drag) return
|
|
193
|
+
const dismissed = drag.owned && (drag.by > windowEl.value!.offsetHeight * DISMISS_TRAVEL || drag.velocity > DISMISS_VELOCITY)
|
|
194
|
+
resetDrag()
|
|
195
|
+
if (dismissed) onCancel()
|
|
196
|
+
}
|
|
197
|
+
</script>
|
|
198
|
+
<style lang="scss" scoped>
|
|
199
|
+
@use '@greendrake/scss-kit' as *;
|
|
200
|
+
.Modal {
|
|
201
|
+
position: fixed;
|
|
202
|
+
top: 0;
|
|
203
|
+
bottom: 0;
|
|
204
|
+
left: 0;
|
|
205
|
+
right: 0;
|
|
206
|
+
z-index: z('modal');
|
|
207
|
+
display: flex;
|
|
208
|
+
justify-content: center;
|
|
209
|
+
align-items: center;
|
|
210
|
+
|
|
211
|
+
// Narrow viewports get the bottom-sheet presentation: full-width window
|
|
212
|
+
// pinned to the bottom edge, rounded top corners, internal scroll. One
|
|
213
|
+
// rule here converts every modal in the app to the mobile idiom (F14).
|
|
214
|
+
@include respond-below('narrow') {
|
|
215
|
+
align-items: flex-end;
|
|
216
|
+
|
|
217
|
+
.Modal__window {
|
|
218
|
+
width: 100%;
|
|
219
|
+
max-width: none;
|
|
220
|
+
max-height: calc(100dvh - var(--space-5));
|
|
221
|
+
overflow-y: auto;
|
|
222
|
+
border-radius: calc(var(--modal-border-radius) * 3) calc(var(--modal-border-radius) * 3) 0 0;
|
|
223
|
+
padding-bottom: var(--safe-area-inset-bottom, 0px);
|
|
224
|
+
// Drag-to-dismiss moves this element directly, suppressing the
|
|
225
|
+
// transition while the finger is down; what is left for the
|
|
226
|
+
// transition is the spring home from a drag released short of
|
|
227
|
+
// dismissal.
|
|
228
|
+
transition: transform 0.2s ease-out;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
&::before {
|
|
233
|
+
position: absolute;
|
|
234
|
+
content: '';
|
|
235
|
+
top: 0;
|
|
236
|
+
bottom: 0;
|
|
237
|
+
left: 0;
|
|
238
|
+
right: 0;
|
|
239
|
+
backdrop-filter: var(--modal-backdrop-filter, none);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
&__main {
|
|
243
|
+
padding: var(--space-4);
|
|
244
|
+
flex-grow: 1;
|
|
245
|
+
:slotted(h2) {
|
|
246
|
+
margin: 0;
|
|
247
|
+
text-align: center;
|
|
248
|
+
}
|
|
249
|
+
// Baseline form layout for a <form> in the modal's main slot. Without
|
|
250
|
+
// this, default flow renders label and input side-by-side and they
|
|
251
|
+
// visually collide inside the narrow modal width. Stack them, give
|
|
252
|
+
// inputs full width, space sibling rows, and hide the redundant
|
|
253
|
+
// submit button (Modal renders its own OK that drives @ok — the
|
|
254
|
+
// submit button is kept in markup only for Enter-to-submit semantics).
|
|
255
|
+
:slotted(form) > div:not(:first-child) {
|
|
256
|
+
margin-top: var(--space-2);
|
|
257
|
+
}
|
|
258
|
+
:slotted(form) label {
|
|
259
|
+
display: block;
|
|
260
|
+
}
|
|
261
|
+
:slotted(form) input[type='text'],
|
|
262
|
+
:slotted(form) input[type='email'],
|
|
263
|
+
:slotted(form) input[type='password'],
|
|
264
|
+
:slotted(form) textarea,
|
|
265
|
+
:slotted(form) .Password {
|
|
266
|
+
width: 100%;
|
|
267
|
+
}
|
|
268
|
+
:slotted(form) > button[type='submit'] {
|
|
269
|
+
display: none;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
&__window {
|
|
274
|
+
position: relative;
|
|
275
|
+
border-radius: var(--modal-border-radius);
|
|
276
|
+
z-index: z('raised');
|
|
277
|
+
background-color: var(--modal-background);
|
|
278
|
+
box-shadow: var(--modal-box-shadow, none);
|
|
279
|
+
display: flex;
|
|
280
|
+
flex-direction: column;
|
|
281
|
+
|
|
282
|
+
&::before {
|
|
283
|
+
border-radius: var(--modal-border-radius);
|
|
284
|
+
position: absolute;
|
|
285
|
+
content: '';
|
|
286
|
+
top: 0;
|
|
287
|
+
bottom: 0;
|
|
288
|
+
left: 0;
|
|
289
|
+
right: 0;
|
|
290
|
+
z-index: z('below');
|
|
291
|
+
backdrop-filter: var(--modal-window-backdrop-filter, none);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
&__header {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
justify-content: space-between;
|
|
299
|
+
gap: var(--space-2);
|
|
300
|
+
padding: var(--space-3) var(--space-4) 0;
|
|
301
|
+
.Modal__title {
|
|
302
|
+
font-weight: 600;
|
|
303
|
+
font-size: var(--font-lg);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
&__toolbar {
|
|
308
|
+
position: absolute;
|
|
309
|
+
top: 6px;
|
|
310
|
+
right: 6px;
|
|
311
|
+
z-index: z('raised');
|
|
312
|
+
display: flex;
|
|
313
|
+
gap: var(--space-1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
&__toolbar-btn {
|
|
317
|
+
@include icon;
|
|
318
|
+
// Grow the hit area to the AA minimum; the mask keeps its intrinsic
|
|
319
|
+
// glyph size centred, so only the target — not the icon — enlarges.
|
|
320
|
+
min-width: 24px;
|
|
321
|
+
min-height: 24px;
|
|
322
|
+
padding: 0;
|
|
323
|
+
background-color: var(--font-color);
|
|
324
|
+
opacity: 0.5;
|
|
325
|
+
&:hover {
|
|
326
|
+
opacity: 1;
|
|
327
|
+
}
|
|
328
|
+
&.close {
|
|
329
|
+
@include i('close');
|
|
330
|
+
}
|
|
331
|
+
&.expand {
|
|
332
|
+
@include i('expand');
|
|
333
|
+
}
|
|
334
|
+
&.collapse {
|
|
335
|
+
@include i('collapse');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
&--expanded &__window {
|
|
340
|
+
width: 100vw;
|
|
341
|
+
height: 100vh;
|
|
342
|
+
max-width: 100vw;
|
|
343
|
+
max-height: 100vh;
|
|
344
|
+
border-radius: 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
&__buttons {
|
|
348
|
+
padding: 0 var(--space-4) var(--space-4);
|
|
349
|
+
display: flex;
|
|
350
|
+
justify-content: space-between;
|
|
351
|
+
gap: var(--space-4);
|
|
352
|
+
|
|
353
|
+
&:has(button:only-child) {
|
|
354
|
+
justify-content: center;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
button {
|
|
358
|
+
min-width: var(--modal-button-min-width, 64px);
|
|
359
|
+
&:disabled {
|
|
360
|
+
@include disabled;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
</style>
|