@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,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>
@@ -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>