@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,262 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
import type { SearchResultItem } from './AsyncSearchResults.vue'
|
|
4
|
+
|
|
5
|
+
// isSearching is handed to the search function so it can drive the spinner
|
|
6
|
+
// for exactly the duration of its backend call (e.g. via an api pendingRef).
|
|
7
|
+
export type SearchFunction = (query: string, isSearching: Ref<boolean>) => Promise<SearchResultItem[]>
|
|
8
|
+
</script>
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
import { h, inject, ref, watch, onBeforeUnmount, useSlots, type Component } from 'vue'
|
|
11
|
+
import { useTippy } from 'vue-tippy'
|
|
12
|
+
import { randomString } from '@greendrake/util'
|
|
13
|
+
import SearchField from './SearchField.vue'
|
|
14
|
+
import AsyncSearchResults from './AsyncSearchResults.vue'
|
|
15
|
+
import { inModalKey } from './inModal'
|
|
16
|
+
|
|
17
|
+
const slots = useSlots()
|
|
18
|
+
const query = ref('')
|
|
19
|
+
const emit = defineEmits<{
|
|
20
|
+
result: [data: SearchResultItem]
|
|
21
|
+
}>()
|
|
22
|
+
const props = withDefaults(
|
|
23
|
+
defineProps<{
|
|
24
|
+
search: SearchFunction
|
|
25
|
+
placeholder?: string
|
|
26
|
+
autofocus?: boolean
|
|
27
|
+
itemComponent?: Component | null
|
|
28
|
+
searchKey?: unknown
|
|
29
|
+
// Minimum query length before a search fires. Lower it for vocabularies
|
|
30
|
+
// with legitimately short entries (e.g. two-letter brand names).
|
|
31
|
+
minQueryLength?: number
|
|
32
|
+
}>(),
|
|
33
|
+
{
|
|
34
|
+
itemComponent: undefined,
|
|
35
|
+
searchKey: undefined,
|
|
36
|
+
minQueryLength: 3
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
let searchID: string | undefined
|
|
40
|
+
const isSearching = ref(false)
|
|
41
|
+
// Combobox state for AT: expanded while the results surface is up, with
|
|
42
|
+
// aria-controls tying the input to the (teleported) listbox.
|
|
43
|
+
const expanded = ref(false)
|
|
44
|
+
const listboxId = `async-search-${randomString()}`
|
|
45
|
+
const cmp = ref<InstanceType<typeof SearchField> | null>(null)
|
|
46
|
+
|
|
47
|
+
// Narrow viewports swap the anchored popper (which clips against small
|
|
48
|
+
// screens and on-screen keyboards) for a full-width results panel. Tracked
|
|
49
|
+
// live so a viewport change mid-session picks the right surface on the NEXT
|
|
50
|
+
// search (the open one is torn down on any change anyway).
|
|
51
|
+
const narrowQuery = window.matchMedia('(max-width: 600px)')
|
|
52
|
+
const isNarrow = ref(narrowQuery.matches)
|
|
53
|
+
const onNarrowChange = (e: MediaQueryListEvent): void => {
|
|
54
|
+
isNarrow.value = e.matches
|
|
55
|
+
}
|
|
56
|
+
narrowQuery.addEventListener('change', onNarrowChange)
|
|
57
|
+
onBeforeUnmount(() => narrowQuery.removeEventListener('change', onNarrowChange))
|
|
58
|
+
|
|
59
|
+
// In the page the panel takes the viewport over, pinned under the input. In a
|
|
60
|
+
// modal it stays in flow: the sheet is already the narrow-viewport takeover
|
|
61
|
+
// surface, it paints over anything pinned to the viewport, and when it sits on
|
|
62
|
+
// the bottom edge there is no room under the input to pin to.
|
|
63
|
+
const inModal = inject(inModalKey, false)
|
|
64
|
+
|
|
65
|
+
// The panel's dataset (null = closed) and, when pinned, its measured top edge.
|
|
66
|
+
const panelResults = ref<SearchResultItem[] | null>(null)
|
|
67
|
+
const panelTop = ref(0)
|
|
68
|
+
|
|
69
|
+
let destroyTippy: (() => void) | null = null
|
|
70
|
+
let hideTippy: (() => void) | null = null
|
|
71
|
+
const closeResults = () => {
|
|
72
|
+
if (destroyTippy) {
|
|
73
|
+
destroyTippy()
|
|
74
|
+
destroyTippy = null
|
|
75
|
+
hideTippy = null
|
|
76
|
+
}
|
|
77
|
+
panelResults.value = null
|
|
78
|
+
expanded.value = false
|
|
79
|
+
}
|
|
80
|
+
const clear = () => {
|
|
81
|
+
query.value = ''
|
|
82
|
+
closeResults()
|
|
83
|
+
}
|
|
84
|
+
onBeforeUnmount(closeResults)
|
|
85
|
+
const isValidQuery = (q: string): boolean => !!q && q.length >= props.minQueryLength
|
|
86
|
+
|
|
87
|
+
const onPick = (data: SearchResultItem) => {
|
|
88
|
+
emit('result', data)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const executeSearch = async () => {
|
|
92
|
+
closeResults()
|
|
93
|
+
if (!isValidQuery(query.value)) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
searchID = randomString()
|
|
97
|
+
const currentSearchId = searchID
|
|
98
|
+
const data = await props.search(query.value, isSearching)
|
|
99
|
+
// Race guard: if a new search started while this one was in flight, its
|
|
100
|
+
// results are stale — drop them and let the newer search present its own.
|
|
101
|
+
closeResults()
|
|
102
|
+
if (currentSearchId === searchID && !!query.value) {
|
|
103
|
+
if (isNarrow.value) {
|
|
104
|
+
// Full-width panel under the input (F9/F14) — rendered in this
|
|
105
|
+
// component's template, so the footer slot and pick handling are
|
|
106
|
+
// shared with the popper path.
|
|
107
|
+
if (!inModal) {
|
|
108
|
+
panelTop.value = (cmp.value?.$el as HTMLElement | undefined)?.getBoundingClientRect().bottom ?? 0
|
|
109
|
+
}
|
|
110
|
+
panelResults.value = data
|
|
111
|
+
expanded.value = true
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const { destroy, hide: tippyHide } = useTippy(cmp, {
|
|
115
|
+
theme: 'asyncSearchResults',
|
|
116
|
+
trigger: 'manual',
|
|
117
|
+
// The popper is a listbox surface, not a tooltip; keep tippy from
|
|
118
|
+
// stamping tooltip semantics/aria onto it or the input.
|
|
119
|
+
role: 'presentation',
|
|
120
|
+
aria: { content: null, expanded: false },
|
|
121
|
+
maxWidth: 'none',
|
|
122
|
+
placement: 'bottom-start',
|
|
123
|
+
offset: [0, -1],
|
|
124
|
+
arrow: false,
|
|
125
|
+
showOnCreate: true,
|
|
126
|
+
interactive: true,
|
|
127
|
+
content: h(
|
|
128
|
+
AsyncSearchResults,
|
|
129
|
+
{
|
|
130
|
+
data,
|
|
131
|
+
listboxId,
|
|
132
|
+
itemComponent: props.itemComponent,
|
|
133
|
+
// Only surface the pick — don't clear here. What happens next is the
|
|
134
|
+
// consumer's call (navigate, close, or clear via the exposed clear());
|
|
135
|
+
// the results stay put until the query changes or this component
|
|
136
|
+
// unmounts (e.g. when a navigation the pick triggered finally commits).
|
|
137
|
+
onPick
|
|
138
|
+
},
|
|
139
|
+
// The popper renders outside the app's render tree, so the host's
|
|
140
|
+
// slots are handed to the vnode explicitly: default = the footer
|
|
141
|
+
// strip, empty = the host's own zero-result surface.
|
|
142
|
+
{
|
|
143
|
+
...(slots['resultsFooter'] ? { default: slots['resultsFooter'] } : {}),
|
|
144
|
+
...(slots['empty'] ? { empty: slots['empty'] } : {})
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
destroyTippy = destroy
|
|
149
|
+
hideTippy = tippyHide
|
|
150
|
+
expanded.value = true
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Enter forces an immediate search on the current input. flush() commits the
|
|
155
|
+
// un-debounced value and cancels any pending debounced update; if that changed the
|
|
156
|
+
// query, the watcher below runs the search — so only re-trigger here when it did
|
|
157
|
+
// not (the re-summon-after-dismissal case), avoiding a double search.
|
|
158
|
+
const onEnter = () => {
|
|
159
|
+
if (!cmp.value?.flush()) {
|
|
160
|
+
executeSearch()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
watch(query, async v => {
|
|
165
|
+
closeResults()
|
|
166
|
+
if (isValidQuery(v)) {
|
|
167
|
+
await executeSearch()
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Re-run search when searchKey changes (if there's a valid query)
|
|
172
|
+
watch(
|
|
173
|
+
() => props.searchKey,
|
|
174
|
+
async () => {
|
|
175
|
+
if (isValidQuery(query.value)) {
|
|
176
|
+
await executeSearch()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
// Dismiss the results popper WITHOUT clearing the query (unlike clear) — keeps the
|
|
181
|
+
// query intact so a footer action that reads it (e.g. "Save this search") still works,
|
|
182
|
+
// and stops the popper from covering any modal that action opens.
|
|
183
|
+
const hide = () => hideTippy?.()
|
|
184
|
+
defineExpose({
|
|
185
|
+
clear,
|
|
186
|
+
hide
|
|
187
|
+
})
|
|
188
|
+
</script>
|
|
189
|
+
<template>
|
|
190
|
+
<div class="AsyncSearch" @keydown.enter.prevent="onEnter">
|
|
191
|
+
<!-- Enter searches the current input immediately and can re-summon the results
|
|
192
|
+
box after it was dismissed (e.g. by refocusing the field), which otherwise
|
|
193
|
+
needs editing the query. See onEnter; .prevent stops a stray form submit.
|
|
194
|
+
Listener on the root div (not the SearchField component) so it fires once,
|
|
195
|
+
free of attribute fallthrough. A root-level comment would make this a
|
|
196
|
+
fragment in dev builds (breaking $el consumers), hence it sits inside. -->
|
|
197
|
+
<SearchField v-bind="{ ...$attrs, role: 'combobox', 'aria-autocomplete': 'list', 'aria-expanded': expanded ? 'true' : 'false', 'aria-controls': listboxId }" ref="cmp" v-model="query" :placeholder="placeholder" :autofocus="autofocus" :class="{ spinner: isSearching }" :debounce="true" />
|
|
198
|
+
<!-- Teleported only when pinned to the viewport; in a modal the panel is
|
|
199
|
+
left in flow, inside the sheet (and inside its focus/inert scope). -->
|
|
200
|
+
<Teleport to="body" :disabled="inModal">
|
|
201
|
+
<div v-if="panelResults" class="AsyncSearch__panel" :class="{ 'AsyncSearch__panel--pinned': !inModal }" :style="inModal ? undefined : { top: `${panelTop}px` }" @keydown.esc="closeResults">
|
|
202
|
+
<AsyncSearchResults :data="panelResults" :listbox-id="listboxId" :item-component="itemComponent" @pick="onPick">
|
|
203
|
+
<!-- Forwarded only when the host actually supplies it, so an
|
|
204
|
+
empty pass-through can't shadow the default message. -->
|
|
205
|
+
<template v-if="$slots.empty" #empty><slot name="empty" /></template>
|
|
206
|
+
<slot name="resultsFooter" />
|
|
207
|
+
</AsyncSearchResults>
|
|
208
|
+
</div>
|
|
209
|
+
</Teleport>
|
|
210
|
+
</div>
|
|
211
|
+
</template>
|
|
212
|
+
<style lang="scss">
|
|
213
|
+
@use './shared' as *;
|
|
214
|
+
@use '@greendrake/scss-kit' as *;
|
|
215
|
+
.AsyncSearch {
|
|
216
|
+
position: relative;
|
|
217
|
+
|
|
218
|
+
&__panel {
|
|
219
|
+
background: var(--background-color);
|
|
220
|
+
border-top: 1px solid var(--border-color);
|
|
221
|
+
|
|
222
|
+
// Pinned variant: takes the viewport over from the input's bottom edge
|
|
223
|
+
// down to the bottom nav, so the whole remaining screen is results.
|
|
224
|
+
&--pinned {
|
|
225
|
+
position: fixed;
|
|
226
|
+
left: 0;
|
|
227
|
+
right: 0;
|
|
228
|
+
bottom: calc(var(--bottomnav-height, 0px) + var(--safe-area-inset-bottom, 0px));
|
|
229
|
+
z-index: z('sticky');
|
|
230
|
+
overflow-y: auto;
|
|
231
|
+
.AsyncSearchResults {
|
|
232
|
+
max-height: none;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
.SearchField {
|
|
237
|
+
position: relative;
|
|
238
|
+
&.spinner::before {
|
|
239
|
+
@include spinner-inline-right(28px);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
div[data-tippy-root] {
|
|
243
|
+
width: 100%;
|
|
244
|
+
cursor: default;
|
|
245
|
+
}
|
|
246
|
+
.tippy-box[data-theme~='asyncSearchResults'] {
|
|
247
|
+
background-color: var(--background-color);
|
|
248
|
+
border: 1px solid var(--border-color);
|
|
249
|
+
color: var(--font-color);
|
|
250
|
+
// The popper reads as the field's continuation, so it takes the field's
|
|
251
|
+
// corner — and clips to it: the result rows and the host's footer paint
|
|
252
|
+
// their own opaque backgrounds out to the edge and would square the
|
|
253
|
+
// corners off again. Nothing to lose by clipping (this theme has no
|
|
254
|
+
// arrow, and the result list scrolls inside its own box).
|
|
255
|
+
border-radius: var(--border-radius);
|
|
256
|
+
overflow: hidden;
|
|
257
|
+
.tippy-content {
|
|
258
|
+
padding: 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface SearchResultItem {
|
|
3
|
+
id: string
|
|
4
|
+
// Rendered via v-html by the default item renderer — hence the explicit
|
|
5
|
+
// name; build it only from trusted/escaped content. A custom itemComponent
|
|
6
|
+
// may render whatever fields the item carries instead.
|
|
7
|
+
// Required by the default renderer (rendered via v-html — supply
|
|
8
|
+
// trusted/escaped markup); irrelevant when the host passes itemComponent.
|
|
9
|
+
htmlTitle?: string
|
|
10
|
+
[key: string]: unknown
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
import type { Component } from 'vue'
|
|
15
|
+
import { messages } from './messages'
|
|
16
|
+
|
|
17
|
+
withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
data: SearchResultItem[]
|
|
20
|
+
itemComponent?: Component | null
|
|
21
|
+
// The listbox element id the host input's aria-controls points at.
|
|
22
|
+
listboxId?: string
|
|
23
|
+
}>(),
|
|
24
|
+
{
|
|
25
|
+
itemComponent: null,
|
|
26
|
+
listboxId: undefined
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
pick: [data: SearchResultItem]
|
|
31
|
+
}>()
|
|
32
|
+
const onClick = (data: SearchResultItem) => emit('pick', data)
|
|
33
|
+
</script>
|
|
34
|
+
<template>
|
|
35
|
+
<div class="AsyncSearchResults">
|
|
36
|
+
<div v-if="data.length" :id="listboxId" role="listbox" tabindex="0" :aria-label="messages.searchResults">
|
|
37
|
+
<div v-for="d in data" :key="d.id" class="AsyncSearchResults__Result" role="option" :aria-selected="false" @click="onClick(d)">
|
|
38
|
+
<component :is="itemComponent" v-if="itemComponent" :data="d" />
|
|
39
|
+
<span v-else v-html="d.htmlTitle ?? ''" />
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<!-- Hosts that can do better than "nothing found" — offer near-misses,
|
|
43
|
+
an alert, a way to create what is missing — fill the empty slot. -->
|
|
44
|
+
<slot v-else name="empty">
|
|
45
|
+
<div class="AsyncSearchResults__Result nothingFound">{{ messages.nothingFound }}</div>
|
|
46
|
+
</slot>
|
|
47
|
+
<slot />
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
<style lang="scss">
|
|
51
|
+
.AsyncSearchResults {
|
|
52
|
+
max-height: 200px;
|
|
53
|
+
display: flex;
|
|
54
|
+
border-radius: var(--border-radius);
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
> div:first-child {
|
|
57
|
+
overflow-y: auto;
|
|
58
|
+
}
|
|
59
|
+
&__Result {
|
|
60
|
+
padding: var(--space-2);
|
|
61
|
+
background-color: var(--form-background);
|
|
62
|
+
&:not(:last-child) {
|
|
63
|
+
border-bottom: 1px solid var(--border-color-light);
|
|
64
|
+
}
|
|
65
|
+
cursor: default;
|
|
66
|
+
&:hover {
|
|
67
|
+
background-color: var(--form-background-hover);
|
|
68
|
+
}
|
|
69
|
+
&.nothingFound {
|
|
70
|
+
padding: var(--space-2);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface BottomNavTab {
|
|
3
|
+
id: string
|
|
4
|
+
label: string
|
|
5
|
+
// The route this tab navigates to. Omit for a tab that acts on the app
|
|
6
|
+
// shell instead of navigating — an overlay whose existence is a property of
|
|
7
|
+
// the viewport, not of the URL. Such a tab renders as a button and reports
|
|
8
|
+
// its state through `select` + aria-expanded rather than aria-current.
|
|
9
|
+
to?: string
|
|
10
|
+
// Marks this tab active. The app owns the mapping (a tab usually covers a
|
|
11
|
+
// family of routes, not one path), so it is a plain flag, not a matcher.
|
|
12
|
+
active: boolean
|
|
13
|
+
// Optional attention count rendered as a badge on the tab icon.
|
|
14
|
+
badge?: number
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
import { RouterLink } from 'vue-router'
|
|
19
|
+
defineProps<{ tabs: BottomNavTab[] }>()
|
|
20
|
+
const emit = defineEmits<{ select: [id: string] }>()
|
|
21
|
+
</script>
|
|
22
|
+
<template>
|
|
23
|
+
<!-- Fixed bottom tab bar for narrow viewports. Apps style the per-tab icon
|
|
24
|
+
via the .BottomNav__icon--<id> class hook (mask-image pattern) and hide
|
|
25
|
+
the bar on wide viewports; the safe-area inset is honoured here so the
|
|
26
|
+
bar clears the home indicator in native shells. The badge is a sibling
|
|
27
|
+
of the masked icon, not a child — a mask clips its descendants. -->
|
|
28
|
+
<nav class="BottomNav">
|
|
29
|
+
<component
|
|
30
|
+
:is="tab.to ? RouterLink : 'button'"
|
|
31
|
+
v-for="tab in tabs"
|
|
32
|
+
:key="tab.id"
|
|
33
|
+
:to="tab.to"
|
|
34
|
+
:type="tab.to ? undefined : 'button'"
|
|
35
|
+
class="BottomNav__tab"
|
|
36
|
+
:class="{ 'BottomNav__tab--active': tab.active }"
|
|
37
|
+
:aria-current="tab.to && tab.active ? 'page' : undefined"
|
|
38
|
+
:aria-expanded="tab.to ? undefined : tab.active"
|
|
39
|
+
@click="tab.to || emit('select', tab.id)"
|
|
40
|
+
>
|
|
41
|
+
<span class="BottomNav__glyph" aria-hidden="true">
|
|
42
|
+
<span class="BottomNav__icon" :class="`BottomNav__icon--${tab.id}`"></span>
|
|
43
|
+
<span v-if="tab.badge" class="BottomNav__badge">{{ tab.badge }}</span>
|
|
44
|
+
</span>
|
|
45
|
+
<span class="BottomNav__label">{{ tab.label }}</span>
|
|
46
|
+
</component>
|
|
47
|
+
<!-- Trailing cell for something that is not navigation: a page's own
|
|
48
|
+
actions, which on a narrow viewport have nowhere else persistent to
|
|
49
|
+
live. Empty on every page that offers none, and the tabs take the
|
|
50
|
+
whole bar then — so a page with actions narrows them, which is the
|
|
51
|
+
cost of not reserving a cell nothing usually fills. -->
|
|
52
|
+
<slot />
|
|
53
|
+
</nav>
|
|
54
|
+
</template>
|
|
55
|
+
<style lang="scss">
|
|
56
|
+
@use '@greendrake/scss-kit' as *;
|
|
57
|
+
.BottomNav {
|
|
58
|
+
position: fixed;
|
|
59
|
+
left: 0;
|
|
60
|
+
right: 0;
|
|
61
|
+
bottom: 0;
|
|
62
|
+
z-index: z('sticky');
|
|
63
|
+
display: flex;
|
|
64
|
+
height: calc(var(--bottomnav-height) + var(--safe-area-inset-bottom, 0px));
|
|
65
|
+
padding-bottom: var(--safe-area-inset-bottom, 0px);
|
|
66
|
+
background: var(--form-background);
|
|
67
|
+
border-top: 1px solid var(--border-color-light);
|
|
68
|
+
&__tab {
|
|
69
|
+
flex: 1 1 0;
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
gap: 2px;
|
|
75
|
+
color: var(--font-color-muted, var(--font-color));
|
|
76
|
+
text-decoration: none;
|
|
77
|
+
// A tab is a link or, when it opens a shell overlay, a button — which
|
|
78
|
+
// arrives with a UA background, border and font of its own.
|
|
79
|
+
background: none;
|
|
80
|
+
border: none;
|
|
81
|
+
font: inherit;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
&--active {
|
|
84
|
+
color: var(--accent-color);
|
|
85
|
+
.BottomNav__icon {
|
|
86
|
+
background-color: var(--accent-color);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
&__glyph {
|
|
91
|
+
position: relative;
|
|
92
|
+
width: 24px;
|
|
93
|
+
height: 24px;
|
|
94
|
+
}
|
|
95
|
+
&__icon {
|
|
96
|
+
@include icon-base;
|
|
97
|
+
display: block;
|
|
98
|
+
width: 100%;
|
|
99
|
+
height: 100%;
|
|
100
|
+
mask-position: center center;
|
|
101
|
+
background-color: var(--font-color-muted, var(--font-color));
|
|
102
|
+
}
|
|
103
|
+
&__badge {
|
|
104
|
+
position: absolute;
|
|
105
|
+
top: -6px;
|
|
106
|
+
right: -10px;
|
|
107
|
+
background: var(--color-attention-fill, var(--accent-color));
|
|
108
|
+
color: var(--color-attention-contrast, #fff);
|
|
109
|
+
border-radius: 999px;
|
|
110
|
+
padding: 0 5px;
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
line-height: 16px;
|
|
113
|
+
min-width: 16px;
|
|
114
|
+
text-align: center;
|
|
115
|
+
}
|
|
116
|
+
&__label {
|
|
117
|
+
font-size: var(--font-xs);
|
|
118
|
+
line-height: 1;
|
|
119
|
+
// Follow the tab's own colour (muted when inactive, accent when active).
|
|
120
|
+
// Without this the theme's global `a span` link tint paints every label
|
|
121
|
+
// violet, so the active/inactive distinction collapses to just the icon.
|
|
122
|
+
color: inherit;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</style>
|
package/src/Checkbox.vue
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { randomString } from '@greendrake/util'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
label?: string
|
|
7
|
+
// Render as an iOS-style sliding switch instead of the default tick box.
|
|
8
|
+
switch?: boolean
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const value = defineModel<boolean>()
|
|
13
|
+
const id = randomString('checkbox')
|
|
14
|
+
const cls = computed(() => ['Checkbox', { 'Checkbox--switch': props.switch }])
|
|
15
|
+
</script>
|
|
16
|
+
<template>
|
|
17
|
+
<div :class="cls">
|
|
18
|
+
<input :id="id" v-model="value" type="checkbox" :disabled="props.disabled" />
|
|
19
|
+
<label v-if="props.label" :for="id">{{ props.label }}</label>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
<style lang="scss">
|
|
23
|
+
.Checkbox {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: var(--space-2);
|
|
27
|
+
// 24px minimum: WCAG 2.5.8 target size (the box itself is the target).
|
|
28
|
+
--checkbox-size: 24px;
|
|
29
|
+
|
|
30
|
+
&:not(.Checkbox--switch) {
|
|
31
|
+
input[type='checkbox'] {
|
|
32
|
+
padding: 0;
|
|
33
|
+
appearance: none;
|
|
34
|
+
-webkit-appearance: none;
|
|
35
|
+
// Grid-centre the tick glyph — fixed margins would drift off
|
|
36
|
+
// centre the moment the size token changes.
|
|
37
|
+
display: grid;
|
|
38
|
+
place-content: center;
|
|
39
|
+
width: var(--checkbox-size);
|
|
40
|
+
height: var(--checkbox-size);
|
|
41
|
+
max-width: var(--checkbox-size);
|
|
42
|
+
max-height: var(--checkbox-size);
|
|
43
|
+
min-width: var(--checkbox-size);
|
|
44
|
+
min-height: var(--checkbox-size);
|
|
45
|
+
border: 1px solid var(--border-color);
|
|
46
|
+
border-radius: var(--border-radius);
|
|
47
|
+
background: var(--input-background);
|
|
48
|
+
cursor: default;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
input[type='checkbox']:checked::after {
|
|
52
|
+
content: '';
|
|
53
|
+
width: 6px;
|
|
54
|
+
height: 10px;
|
|
55
|
+
border: solid var(--font-color);
|
|
56
|
+
border-width: 0 2px 2px 0;
|
|
57
|
+
// The ink is the glyph box's right+bottom strokes, so the rotated
|
|
58
|
+
// tick reads below/right of the geometric centre; the margin
|
|
59
|
+
// biases the box up to optical centre.
|
|
60
|
+
margin-bottom: 3px;
|
|
61
|
+
transform: rotate(45deg);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// iOS-style sliding switch: a pill track with a knob that slides to the right
|
|
66
|
+
// and the track turns accent-coloured when checked.
|
|
67
|
+
&.Checkbox--switch {
|
|
68
|
+
--switch-width: 40px;
|
|
69
|
+
--switch-height: 24px;
|
|
70
|
+
--switch-knob: calc(var(--switch-height) - 4px);
|
|
71
|
+
|
|
72
|
+
input[type='checkbox'] {
|
|
73
|
+
position: relative;
|
|
74
|
+
padding: 0;
|
|
75
|
+
appearance: none;
|
|
76
|
+
-webkit-appearance: none;
|
|
77
|
+
width: var(--switch-width);
|
|
78
|
+
min-width: var(--switch-width);
|
|
79
|
+
height: var(--switch-height);
|
|
80
|
+
border: none;
|
|
81
|
+
border-radius: var(--switch-height);
|
|
82
|
+
background: var(--border-color);
|
|
83
|
+
transition: background 0.2s ease;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
input[type='checkbox']::after {
|
|
87
|
+
content: '';
|
|
88
|
+
position: absolute;
|
|
89
|
+
top: 2px;
|
|
90
|
+
left: 2px;
|
|
91
|
+
width: var(--switch-knob);
|
|
92
|
+
height: var(--switch-knob);
|
|
93
|
+
border-radius: 50%;
|
|
94
|
+
background: #fff;
|
|
95
|
+
transition: transform 0.2s ease;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
input[type='checkbox']:checked {
|
|
99
|
+
background: var(--accent-color);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
input[type='checkbox']:checked::after {
|
|
103
|
+
transform: translateX(calc(var(--switch-width) - var(--switch-height)));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
input[type='checkbox']:disabled {
|
|
108
|
+
opacity: 0.5;
|
|
109
|
+
cursor: not-allowed;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Geometry is static: r=16 inside a 36×36 viewBox leaves headroom for the 3px
|
|
3
|
+
// stroke (16 + 1.5 = 17.5 < 18). Kept at module scope so it isn't rebuilt per
|
|
4
|
+
// instance.
|
|
5
|
+
const RADIUS = 16
|
|
6
|
+
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
|
7
|
+
</script>
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ progress: number }>()
|
|
12
|
+
|
|
13
|
+
const clamped = computed(() => Math.max(0, Math.min(100, props.progress)))
|
|
14
|
+
// Full circumference at 0%, zero at 100% — the dash grows clockwise from the
|
|
15
|
+
// top (the arc circle is rotated -90deg in CSS).
|
|
16
|
+
const dashOffset = computed(() => CIRCUMFERENCE * (1 - clamped.value / 100))
|
|
17
|
+
</script>
|
|
18
|
+
<template>
|
|
19
|
+
<svg class="CircularProgress" viewBox="0 0 36 36" role="progressbar" :aria-valuenow="Math.round(clamped)" aria-valuemin="0" aria-valuemax="100">
|
|
20
|
+
<circle class="CircularProgress__track" cx="18" cy="18" :r="RADIUS" />
|
|
21
|
+
<circle class="CircularProgress__arc" cx="18" cy="18" :r="RADIUS" :stroke-dasharray="CIRCUMFERENCE" :stroke-dashoffset="dashOffset" />
|
|
22
|
+
<text class="CircularProgress__label" x="18" y="18">{{ Math.round(clamped) }}%</text>
|
|
23
|
+
</svg>
|
|
24
|
+
</template>
|
|
25
|
+
<style lang="scss">
|
|
26
|
+
.CircularProgress {
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: 100%;
|
|
29
|
+
display: block;
|
|
30
|
+
|
|
31
|
+
&__track,
|
|
32
|
+
&__arc {
|
|
33
|
+
fill: none;
|
|
34
|
+
stroke-width: 3;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&__track {
|
|
38
|
+
stroke: var(--circular-progress-track, #c2c6cc);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
&__arc {
|
|
42
|
+
stroke: var(--circular-progress-arc, #2ea043);
|
|
43
|
+
stroke-linecap: round;
|
|
44
|
+
transform: rotate(-90deg);
|
|
45
|
+
transform-origin: 50% 50%;
|
|
46
|
+
transition: stroke-dashoffset 0.15s linear;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&__label {
|
|
50
|
+
fill: var(--circular-progress-label, #fff);
|
|
51
|
+
font-size: 9px;
|
|
52
|
+
font-weight: 600;
|
|
53
|
+
text-anchor: middle;
|
|
54
|
+
dominant-baseline: central;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
</style>
|
package/src/Copy.vue
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { copyTextToClipboard } from './copyTextToClipboard'
|
|
3
|
+
import { messages } from './messages'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
value: string
|
|
8
|
+
showValue?: boolean
|
|
9
|
+
}>(),
|
|
10
|
+
{
|
|
11
|
+
showValue: false
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const onClick = async (e: MouseEvent) => {
|
|
16
|
+
e.preventDefault()
|
|
17
|
+
const target = e.currentTarget as HTMLElement
|
|
18
|
+
await copyTextToClipboard(props.value)
|
|
19
|
+
target.classList.add('copied')
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
if (document.body.contains(target)) {
|
|
22
|
+
target.classList.remove('copied')
|
|
23
|
+
}
|
|
24
|
+
}, 2000)
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
<template>
|
|
28
|
+
<div class="Copy2Clipboard" :title="messages.clickToCopy" @click="onClick">
|
|
29
|
+
<span v-if="showValue" class="Copy2Clipboard__value">{{ value }}</span>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
<style lang="scss">
|
|
33
|
+
@use '@greendrake/scss-kit' as *;
|
|
34
|
+
.Copy2Clipboard {
|
|
35
|
+
cursor: default;
|
|
36
|
+
display: inline-flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 4px;
|
|
39
|
+
&::before {
|
|
40
|
+
content: '';
|
|
41
|
+
@include icon;
|
|
42
|
+
@include i('copy');
|
|
43
|
+
}
|
|
44
|
+
&:hover::before {
|
|
45
|
+
background-color: orange;
|
|
46
|
+
}
|
|
47
|
+
&.copied::before {
|
|
48
|
+
background-color: green;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</style>
|