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