@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Greendrake
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,327 @@
1
+ # @greendrake/ui
2
+
3
+ Vue 3 component library: form controls with blur-time validation, a focus-confining modal stack, async search, tabs, toasts, a page shell, a lazy tree, a paged thumbnail grid and flat multi-selection. Ships as TypeScript + SFC source; the consuming Vite app compiles it.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add @greendrake/ui vue vue-router
9
+ ```
10
+
11
+ `vue` and `vue-router` are peer dependencies (`vue-router` is used by `BottomNav` only). `@greendrake/util`, `@greendrake/domains`, `@greendrake/scss-kit` and `vue-tippy` come in as dependencies.
12
+
13
+ There is no build step and no `.d.ts`: `exports` points at `src/main.ts`, so the consumer's Vite + `vue-tsc` toolchain compiles and type-checks the SFCs directly. They are written against the `vueCompilerOptions` of `@greendrake/dev-config/tsconfig/vue.json` (`strictTemplates`, `data-*` attributes allowed); a consumer's tsconfig extends it:
14
+
15
+ ```json
16
+ {
17
+ "extends": "@greendrake/dev-config/tsconfig/vue.json",
18
+ "include": ["src"]
19
+ }
20
+ ```
21
+
22
+ ## Styling contract
23
+
24
+ Component styles are SCSS inside the SFCs and resolve `@greendrake/scss-kit` (breakpoints, z-layers, icon masks, control mixins) at compile time. Colours, spacing and typography come from CSS custom properties: the consuming app loads `@greendrake/theme` (tokens, document styling, the global `.spinner` rules that `AsyncAction`, `AsyncSearch`, `TreeView`, `ThumbnailGrid` and `Gallery` toggle) and defines the app-provided properties listed under "CSS custom property contract" in that package's README. Properties that README leaves to this package, each read with a local default:
25
+
26
+ | Property | Read by | Default |
27
+ | --- | --- | --- |
28
+ | `--checkbox-size` | `Checkbox` box | 24px, set on `.Checkbox` |
29
+ | `--switch-width`, `--switch-height`, `--switch-knob` | `Checkbox` with `switch` | 40px / 24px / `calc(var(--switch-height) - 4px)`, set on `.Checkbox--switch` |
30
+ | `--accent-contrast` | `SegmentedControl` active segment text | none |
31
+ | `--font-color-muted` | `SelectBox` placeholder, `BottomNav` inactive tabs | `--vs-text-color` / `--font-color` |
32
+ | `--bottomnav-height` | `BottomNav` bar height; bottom edge of the `AsyncSearch` narrow panel | none / 0px |
33
+ | `--safe-area-inset-top`, `--safe-area-inset-bottom` | toast layer; `Modal` sheet and `BottomNav` padding | 0px |
34
+ | `--max-content-width` | `TheWrap` width cap | none |
35
+ | `--tippy-background-color` | `FieldTip` validation tips | `#5335e8` |
36
+ | `--toast-color`, `--toast-background` | toasts | `#fff`; `--accent-color` (`--warning-color` for the warning variant) |
37
+ | `--color-attention-fill`, `--color-attention-contrast` | `BottomNav` badge | `--accent-color` / `#fff` |
38
+ | `--vs-*` | `SelectBox` (see the component) | per property |
39
+
40
+ Breakpoint: "narrow" is `@greendrake/scss-kit`'s 600px boundary; `Modal`, `TabBar` and `AsyncSearch` switch presentation below it.
41
+
42
+ Tooltips: `FieldTip`, `MenuButton` and `AsyncSearch` use `vue-tippy`. Each ships the styles of its own tippy theme (`fieldTip`, `HMenu`, `asyncSearchResults`); no global tippy.js stylesheet is loaded or required.
43
+
44
+ ## Messages
45
+
46
+ Every user-facing string lives in one reactive `messages` object (`UiMessages`). `configureUiMessages(overrides: Partial<UiMessages>)` merges overrides in; call it at boot and again on locale change.
47
+
48
+ ```ts
49
+ import { configureUiMessages } from '@greendrake/ui'
50
+
51
+ configureUiMessages({ cancel: 'Abbrechen', nothingFound: 'Nichts gefunden' })
52
+ ```
53
+
54
+ Keys and defaults: `loading` "Loading", `ok`, `cancel`, `close`, `clear`, `tryAgain`, `trying` "Trying...", `retryCountdown` "Error. Retrying in {seconds}" (`{seconds}` is substituted at render time), `toHomePage`, `nothingFound`, `searchResults`, `search`, `create`, `refresh`, `delete`, `deleting`, `newTab`, `copied`, `clickToCopy`, `invalidEmail` "Not a valid email address", `selectOption` "Select an option", `errorOther`, `errorNetwork`, `errorNotFound`.
55
+
56
+ ## Form controls
57
+
58
+ ### TextField
59
+
60
+ `v-model: string` — always a string, `''` when blank. Renders an `<input>` (`type` is passed through), a `<textarea>` for `type="textarea"`, or the `richEditor` component for `type="rich"`.
61
+
62
+ Props: `type` (default `'text'`), `placeholder`, `ariaLabel` (defaults to the placeholder), `id` (lands on the input, so `focusIn(id)` and `<label for>` reach it), `autofocus` (pointer devices only, and only when nothing else holds focus), `validator?: (v: string) => true | string`, `validateOnBlur` (default `true`), `richEditor?: Component`. Exposes `focus()`.
63
+
64
+ Validation: while the typed value fails `validator` the model is `''`. On blur of a non-empty invalid value the returned message shows as a tooltip on the input (`FieldTip`); typing dismisses it. A form that provides `validationScopeKey` (see below) suppresses blur validation when focus leaves the form entirely.
65
+
66
+ `richEditor` is an injection point only: any component that honours the same string `v-model` contract (and accepts `ariaLabel`) is rendered in place of the input when `type === 'rich'`. The prop is read once at setup; `type="rich"` without it throws.
67
+
68
+ ```vue
69
+ <script setup lang="ts">
70
+ import { ref } from 'vue'
71
+ import { Field, TextField } from '@greendrake/ui'
72
+
73
+ const slug = ref('')
74
+ const slugRule = (v: string): true | string => /^[a-z0-9-]+$/.test(v) || 'Lowercase letters, digits and dashes only'
75
+ </script>
76
+ <template>
77
+ <Field label="Slug">
78
+ <TextField v-model="slug" :validator="slugRule" placeholder="my-page" />
79
+ </Field>
80
+ </template>
81
+ ```
82
+
83
+ ### Password
84
+
85
+ `v-model: string`. Password input with a show/hide toggle that preserves the caret. Props: `autofocus`, `minLength` (default 1), `id` (lands on the wrapper, so `FieldTip` can anchor on `#<id>`; the inputs are `#<id> input`).
86
+
87
+ ### Email
88
+
89
+ `v-model: string`. `TextField` with `type="email"` and a validator backed by `isEmailValid` from `@greendrake/domains`; the failure message is `messages.invalidEmail`. Props: `placeholder`, `autofocus`, `id`, `validateOnBlur`. Other attributes fall through to the `TextField`.
90
+
91
+ ### Checkbox
92
+
93
+ `v-model: boolean`. Props: `label`, `switch` (iOS-style sliding switch instead of a tick box), `disabled`. Sizing via `--checkbox-size` / `--switch-*` (see Styling contract).
94
+
95
+ ### CurrencyInput
96
+
97
+ `v-model: string | null` — the normalised amount (no thousands separators, `precision` decimals) or `null` when empty; the input itself shows separators as the user types. Props: `symbol` (rendered before the amount), `hideSymbol`, `precision` (default 2), `forceDecimals`, `doNotShowZeroDecimals`, `min` (input rejects values below it), `placeholder`, `ariaLabel`, `autofocus`, `disabled`.
98
+
99
+ ### Field
100
+
101
+ Caption + control column. Props: `label`; `group` wraps the slot in a `role="group"` named by the label (for a set of controls); `unlabelable` leaves the caption as plain text (for a control a `<label>` cannot bind to, e.g. a contenteditable). Without either, the slot renders inside a `<label>` so the caption is the control's accessible name. Default slot.
102
+
103
+ ### FieldTip
104
+
105
+ `FieldTip(source: Ref<string | false>, target: string | Ref<HTMLElement | ComponentPublicInstance | null>): void` — binds a manually-triggered tippy tooltip (theme `fieldTip`) to an element or element id: setting `source` to a string shows it as text, `false` hides it; user dismissal resets `source` to `false`. Call it from `setup`; it cleans up on unmount.
106
+
107
+ ### validationScopeKey
108
+
109
+ `InjectionKey<Ref<HTMLElement | null>>`. A form component provides a ref to its root element under this key; descendant `TextField`s then skip blur validation when focus leaves that element (e.g. the user clicks elsewhere without finishing the form).
110
+
111
+ ## Form submission
112
+
113
+ ### FormC
114
+
115
+ A `<form>` whose submit button doubles as the status display, driven by a state machine: rest states `invalid` / `dirty` / `saved` derive from `refs.valid` / `refs.dirty` (a missing ref counts as `true`); a submission dictates `confirm` → `inprogress` → `success` | `error`, each dictated state expiring back to rest after 2000ms. The root carries `form form-<state>` and the button `button button-<state>`; the button is disabled unless the state is `dirty` or `confirm`.
116
+
117
+ Props: `action?: () => Promise<unknown> | unknown`, `askToConfirm` (first submit enters `confirm`, the next one submits), `refs?: { dirty?: Ref<boolean>; valid?: Ref<boolean> }`, per-state button labels `savedText`, `dirtyText`, `invalidText`, `confirmText`, `inprogressText`, `successText`, `errorText` (a missing label shows the raw state name), `noContent` (empty button, state via classes only), `tryPersistently` (retries `action` until it succeeds via `Retrier` from `@greendrake/util`, showing `messages.retryCountdown` in the button between attempts), `successTimeout` (`true`/unset → 2000ms; a number → that many ms; `false` → skip the success state). Default slot holds the fields.
118
+
119
+ ### AsyncAction
120
+
121
+ Button (or `tag="a"`) for a one-shot async action: label switches between `labelDirty` / `labelDone` / `labelInProgress`, the element disables itself while the promise is pending, with a `spinner` class meanwhile. Props: `promiseMaker: () => Promise<void>` (required), `labelDirty` (required), `labelDone`, `labelInProgress`, `isDirty`, `isValid`, `showSpinner` (default `true`), `type` (default `'button'`), `tag` (default `'button'`). Exposes `run()`.
122
+
123
+ ### OptionSwitch
124
+
125
+ `v-model: T` (`T extends string | number | boolean`). Radio-style switch: clicking an option runs `saveHandler` through the same state machine as `FormC` (optionally with a confirm step) and commits the model on success. Props: `options: OptionsArray<T> | OptionsMap<T>` (required; array entries are bare values or `[value, title]` tuples, the map form is `Map<value, title>`), `label`, `requireConfirmation`, `disabledOptions: T[]`, `saveHandler?: (value: T) => Promise<void> | void`. Emits `click(event)`. A slot named after `String(value)` adds markup to that option. Classes: `.OptionSwitch` gets `saving` / `error`; options get `current`, `confirm`, `disabled`. A model value outside `options` throws at setup.
126
+
127
+ ### SegmentedControl
128
+
129
+ `v-model: string` (required). Pill-shaped radio group backed by real radio inputs. Props: `options: SegmentedOption[]` (`{ value, label }`), `ariaLabel` (names the group; makes the container an explicit `radiogroup`).
130
+
131
+ ## Search
132
+
133
+ ### SearchField
134
+
135
+ `v-model: string`. Text input with search glyph, clear button and Escape-to-clear. Prop `debounce?: boolean | number | ((value: string) => number)` delays model updates: `true` → 600ms, a number → that many ms, a function → decided per keystroke from the prospective value. Also `placeholder`, `autofocus`. Exposes `flush(): boolean` — commits the un-debounced value immediately, dropping any pending update; returns whether the model changed. `class`/`style` land on the root, every other attribute on the inner input.
136
+
137
+ ### AsyncSearch
138
+
139
+ `SearchField` plus a results surface. Props: `search: SearchFunction` (required), `placeholder`, `autofocus`, `itemComponent?: Component | null` (rendered per result with a `data` prop; the default renderer shows `htmlTitle` via `v-html`), `searchKey?: unknown` (re-runs the current query when it changes), `minQueryLength` (default 3). Emits `result(data: SearchResultItem)` on pick — nothing else happens on pick; the host navigates, or calls the exposed `clear()` (empties the query) or `hide()` (dismisses the results, query kept). Slots: `resultsFooter` (strip under the list), `empty` (replaces the "nothing found" row). Enter searches the current input immediately; stale responses of superseded queries are dropped.
140
+
141
+ ```ts
142
+ type SearchFunction = (query: string, isSearching: Ref<boolean>) => Promise<SearchResultItem[]>
143
+ interface SearchResultItem {
144
+ id: string
145
+ htmlTitle?: string // trusted/escaped markup; used by the default renderer only
146
+ [key: string]: unknown
147
+ }
148
+ ```
149
+
150
+ `isSearching` is handed to the function so it can drive the field's spinner for exactly the duration of its call. Wide viewports show the results in a tippy popper anchored under the field (theme `asyncSearchResults`); below the narrow breakpoint they take over the viewport from the field's bottom edge down to `--bottomnav-height`, unless the field sits inside a `Modal`, where the panel stays in flow.
151
+
152
+ ## Modals
153
+
154
+ ### Modal
155
+
156
+ Centred dialog, or a bottom sheet with drag-to-dismiss below the narrow breakpoint. Teleported to `body`; attributes (e.g. `class`) land on the teleported root. The consumer owns visibility (`v-if`): `ok` and `cancel` are emits only.
157
+
158
+ Props: `title` (titled header with an explicit close), `closable` (X in the toolbar), `expandable` (full-viewport toggle), `buttons` (default `true`), `okLabel` / `cancelLabel` (default `messages.ok` / `messages.cancel`), `okDisabled`, `hideOk`, `hideCancel`, `mainClass` (class on the content area), `initialFocus: 'field' | 'dialog'` (default `'field'`; see the stack). Emits `ok`, `cancel`. Slots: default (content), `buttons` (replaces the button row), `ok` and `cancel` (button contents). Exposes `rootEl`.
159
+
160
+ The modal stack is module-level and automatic: every mounted `Modal` registers. Escape and `popstate` close the top-most modal only (through its `cancel` emit); everything outside the top-most modal's root is made `inert`, Tab wraps inside it, stray focus is pulled back in; initial focus goes to the first field (to the dialog itself for `initialFocus="dialog"` or on touch devices, where a focused field would summon the keyboard); closing restores focus to the invoker; body scroll is locked while any modal is open.
161
+
162
+ ```vue
163
+ <script setup lang="ts">
164
+ import { ref } from 'vue'
165
+ import { Modal, TextField } from '@greendrake/ui'
166
+
167
+ const props = defineProps<{ rename: (title: string) => Promise<void> }>()
168
+ const open = ref(false)
169
+ const title = ref('')
170
+ const save = async (): Promise<void> => {
171
+ await props.rename(title.value)
172
+ open.value = false
173
+ }
174
+ </script>
175
+ <template>
176
+ <button type="button" @click="open = true">Rename</button>
177
+ <Modal v-if="open" title="Rename" :ok-disabled="!title" @ok="save" @cancel="open = false">
178
+ <TextField v-model="title" placeholder="New title" />
179
+ </Modal>
180
+ </template>
181
+ ```
182
+
183
+ ### ModalOnClick
184
+
185
+ Trigger element plus a `Modal` around the `content` slot. The default slot is the trigger (`tag`, default `'a'`; `id`, `type` and other attributes land on it); `modalClass` goes to the modal. Modal chrome props `title`, `okLabel`, `cancelLabel`, `okDisabled`, `hideOk`, `closable`, `expandable`, `buttons` are forwarded as given, unset ones keeping `Modal`'s defaults. Emits `click(event)` and `open` on trigger, `ok` (re-emitted without closing — an async submitter keeps the modal open on failure and closes via the exposed `onCancel()` on success), `cancel` (closes, then re-emits). Exposes `open(event)`, `onCancel()`.
186
+
187
+ ### Gallery
188
+
189
+ Full-screen image viewer in an expanded `Modal`. Props: `items: GalleryItem[]` (`{ url, alt?, caption? }`), `startIndex` (default 0). Emits `cancel`. Arrow keys and horizontal swipes navigate; a swipe past either end, a tap outside the image, or the image click closes.
190
+
191
+ ## Menus and selects
192
+
193
+ ### MenuButton
194
+
195
+ Hover/click dropdown built on `vue-tippy`'s `Tippy` (theme `HMenu`, placement `bottom-end`); any click inside the menu closes it. Slots: `main` (trigger), `menuitems` (menu content, wrapped in `.Menubutton-Menu`). Props: `menuClass` (on the wrapper), `boxClass` (on the tippy box, applied once on create), `offset` (default `[0, -1]`), `matchTriggerWidth` (pins the popper width to the trigger's).
196
+
197
+ ### SelectBox
198
+
199
+ `v-model: T | null` (`T extends string | number`; `null` = no selection, shows the placeholder). Single-select dropdown with keyboard support (arrows, Enter, Escape closes the menu without reaching a hosting modal). Props: `options: SelectBoxOption<T>[]` (`{ value, label, disabled?, depth? }` — `depth` indents the option in the menu), `placeholder` (default `messages.selectOption`), `ariaLabel`, `clearable`, `searchable` (type-to-filter input while open), `disabled`, `htmlLabel` (labels via `v-html`; trusted content only). Emits `pick(value)` on every menu selection, including re-picking the current one. Exposes `open()`. The menu is positioned against the viewport, flips upward or clamps its height when fixed chrome would cover it, and follows scroll and on-screen-keyboard changes.
200
+
201
+ Skin via `--vs-*`, each with a fallback: `--vs-height` (unset → auto), `--vs-padding` (`4px 8px`), `--vs-line-height` (`normal`), `--vs-border` (`1px solid #e4e4e7`), `--vs-border-radius` (`4px`), `--vs-input-bg` (`#fff`), `--vs-text-color` (`inherit`), `--vs-menu-height` (`200px`), `--vs-menu-bg` (`#fff`), `--vs-option-padding` (`8px 12px`), `--vs-option-text-color`, `--vs-option-hover-color` (`#dbeafe`), `--vs-option-selected-color` (`#93c5fd`), `--vs-option-selected-text-color`.
202
+
203
+ ## Tabs
204
+
205
+ ### TabBar
206
+
207
+ Props: `tabs: TabDef[]` (`{ id, label, closable? }`), `modelValue: string | null`, `closable` (default `false`; per-tab `closable` overrides). Emits `update:modelValue(id)`, `close(id)`. The default slot renders a panel under the strip; below the narrow breakpoint the strip becomes a vertical accordion with the panel directly under the active tab.
208
+
209
+ ```vue
210
+ <script setup lang="ts">
211
+ import { ref } from 'vue'
212
+ import { TabBar, type TabDef } from '@greendrake/ui'
213
+
214
+ const tabs: TabDef[] = [
215
+ { id: 'general', label: 'General' },
216
+ { id: 'security', label: 'Security' }
217
+ ]
218
+ const active = ref<string | null>('general')
219
+ </script>
220
+ <template>
221
+ <TabBar v-model="active" :tabs="tabs">
222
+ <GeneralPane v-if="active === 'general'" />
223
+ <SecurityPane v-else-if="active === 'security'" />
224
+ </TabBar>
225
+ </template>
226
+ ```
227
+
228
+ ## Toasts
229
+
230
+ `showToast(options: ToastOptions): ToastHandle` — imperative top-centre toast, rendered without an app context (callable from stores and plain modules). Toasts raised together stack in order.
231
+
232
+ ```ts
233
+ interface ToastOptions {
234
+ content: string | VNode // text, or a VNode with its handlers already bound
235
+ variant?: 'accent' | 'warning' // default 'accent'
236
+ hideOnClick?: boolean // default true: dismissed by the next key press / pointer down outside the toast layer
237
+ onHidden?: () => void
238
+ }
239
+ ```
240
+
241
+ ```ts
242
+ import { showToast } from '@greendrake/ui'
243
+
244
+ showToast({ content: 'Saved' })
245
+ const failure = showToast({ content: 'Upload failed', variant: 'warning', hideOnClick: false })
246
+ failure.destroy()
247
+ ```
248
+
249
+ ## Layout
250
+
251
+ ### TheWrap
252
+
253
+ Page shell: `top` slot and default (routed content) stretch, the `bottom` slot is pushed to the viewport bottom on short pages; a lone child (e.g. a splash) is centred vertically. Width capped by `--max-content-width`.
254
+
255
+ ### BottomNav
256
+
257
+ Fixed bottom tab bar for narrow viewports; the app hides it on wide ones. Prop `tabs: BottomNavTab[]`; emits `select(id)`. Default slot is a trailing cell for page actions.
258
+
259
+ ```ts
260
+ interface BottomNavTab {
261
+ id: string
262
+ label: string
263
+ to?: string // route; omit for a tab that toggles a shell overlay (rendered as a button, reports via `select` + aria-expanded)
264
+ active: boolean // the app owns the route → tab mapping
265
+ badge?: number
266
+ }
267
+ ```
268
+
269
+ Per-tab icons are the app's: style `.BottomNav__icon--<id>` with a `mask-image`. The bar's height is `--bottomnav-height` plus `--safe-area-inset-bottom`.
270
+
271
+ ### Splash, Loading
272
+
273
+ `Splash` centres its default slot (logo) above a `Loading` indicator, covering its positioned ancestor. `Loading` renders `messages.loading` with animated dots.
274
+
275
+ ## Tree
276
+
277
+ ### TreeView
278
+
279
+ Lazy tree rendered as one flat list of visible nodes. Props: `loader: TreeLoader` (required), `idField` (default `'key'`), `labelField` (default `'title'`), `multiSelect` (default `true`). `v-model:selected: TreeNodeId[]`. Children load on first expand and are cached per node. Selection is mouse-driven: click selects, Shift extends a range over the visible list, Ctrl/Cmd toggles.
280
+
281
+ ```ts
282
+ interface TreeNode {
283
+ expandable: boolean
284
+ [key: string]: unknown // must include [idField] and [labelField]
285
+ }
286
+ type TreeNodeId = string | number
287
+ type TreeLoader = (parentId: TreeNodeId | null) => Promise<TreeNode[]> // null → root level
288
+ ```
289
+
290
+ ### Accordion
291
+
292
+ Independently collapsible sections, all open initially. Prop `sections: AccordionSection[]` (`{ id, title, ...extra }`). Slots: default and `header`, both receiving `{ section }`; `header` holds per-section controls that do not toggle.
293
+
294
+ ## Thumbnails
295
+
296
+ ### ThumbnailGrid
297
+
298
+ Paged multi-select grid of thumbnails. Props: `loader: ThumbnailGridLoader` (required), `filters` (reloads from the first page when it changes), `thumbnailField` (default `'thumbnail'`), `titleField` (default `'title'`), `idField` (default `'node_id'`), `pageSize` (default 24). `v-model:selected: Array<string | number>`; clicking a tile toggles it. Exposes `refresh()`.
299
+
300
+ ```ts
301
+ interface ThumbnailGridLoaderParams {
302
+ offset: number
303
+ limit: number
304
+ [key: string]: unknown // the filters, spread in
305
+ }
306
+ type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<boolean>) => Promise<{ data: ThumbnailGridItem[]; total: number }>
307
+ ```
308
+
309
+ ## Selection
310
+
311
+ ### useSelection
312
+
313
+ `useSelection<Id extends string | number>(items: Ref<Record<string, unknown>[]> | ComputedRef<...>, idField: string)` — flat, index-addressable multi-selection over an ordered list. Returns `{ selected: Set<Id> (shallowReactive), getId(index), indexOf(id), addRange(fromId, toId), toggle(id), replace(id), rangeTo(id) (from the anchor set by the last toggle/replace), moveCursor('up' | 'down', extend: boolean): number (returns the new cursor index), selectAll(), clear() }`. `TreeView` and `@greendrake/ui-data`'s `InfiniteScrollTable` use it.
314
+
315
+ ### RowSelect
316
+
317
+ `RowSelect<Id>(items, idField, onReorder?: (orderedIds: Id[]) => void)` — pointer-driven selection for a `<table>` host: click / Ctrl / Shift row selection via `useSelection`, rubber-band drag over empty space, and, when `onReorder` is given, drag-to-reorder of rows. Returns `{ selected, containerRef, band, reorder, onPointerDown, onKeyDown }`: bind `containerRef` to the table's scroll container and wire `onPointerDown` / `onKeyDown` to it; `band` (`{ active, left, top, width, height }`) and `reorder` (`{ active, fromIndex, toIndex }`) are reactive state to render the band rectangle and the drop indicator from. Interactive descendants (`a`, `button`, inputs, `[role="button"]`) are left alone.
318
+
319
+ ## Misc
320
+
321
+ - `CircularProgress` — SVG ring, prop `progress: number` (0–100, clamped), `role="progressbar"`; colours via `--circular-progress-track` / `--circular-progress-arc` / `--circular-progress-label`.
322
+ - `Copy` — click-to-copy icon for `value: string`; `showValue` renders the value beside it; a `copied` class marks success for 2s. Title is `messages.clickToCopy`.
323
+ - `copyTextToClipboard(text: string): Promise<void>` — `navigator.clipboard.writeText`; requires a secure context.
324
+ - `downsizeImage(file: File, opts?: { maxDimension?: number; maxSizeKB?: number; minQuality?: number }): Promise<File>` — re-encodes an oversized image as JPEG, scaling to `maxDimension` (2000) and stepping quality down until under `maxSizeKB` (1024) or `minQuality` (0.2). Non-images, files within both limits, and undecodable files pass through untouched.
325
+ - `scrollToTop()` — `window.scroll({ top: 0 })`.
326
+ - `focusIn(el: string | HTMLElement | null, selector?: string)` — focuses the element with that id, or the first `selector` match inside `el`, unless it already has focus.
327
+ - `TOUCH_PAN_SLOP_PX` — 10; the travel a touch must cover before it counts as a directional gesture (shared with `@greendrake/ui-data`'s table panning).
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@greendrake/ui",
3
+ "version": "0.1.0",
4
+ "description": "Vue 3 component library: form controls, modals, search, tabs, toasts, layout",
5
+ "license": "MIT",
6
+ "author": "Eugene Greendrake <eugene@greendrake.nz>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/greendrake/lib.git",
10
+ "directory": "ui"
11
+ },
12
+ "homepage": "https://github.com/greendrake/lib/tree/main/ui#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/greendrake/lib/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "src/main.ts",
18
+ "types": "src/main.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/main.ts",
22
+ "default": "./src/main.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "src"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "check": "vue-tsc --noEmit"
33
+ },
34
+ "prettier": "@greendrake/dev-config/prettier",
35
+ "dependencies": {
36
+ "@greendrake/domains": "0.1.0",
37
+ "@greendrake/scss-kit": "0.1.0",
38
+ "@greendrake/util": "0.1.0",
39
+ "vue-tippy": "^6.7.1"
40
+ },
41
+ "peerDependencies": {
42
+ "vue": "^3.5.34",
43
+ "vue-router": "^4.6.4"
44
+ },
45
+ "devDependencies": {
46
+ "@greendrake/dev-config": "0.1.0",
47
+ "typescript": "6.0.3",
48
+ "vue": "^3.5.34",
49
+ "vue-router": "^4.6.4",
50
+ "vue-tsc": "^2.2.0"
51
+ }
52
+ }
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ // Extra fields pass through to the section/header slots.
3
+ export interface AccordionSection {
4
+ id: string
5
+ title: string
6
+ [key: string]: unknown
7
+ }
8
+ </script>
9
+ <script setup lang="ts">
10
+ // Vertical stack of independently-collapsible sections. Multiple sections may be
11
+ // open at once (all start open). Each section gets a clickable title (toggles
12
+ // open) plus a `header` slot for per-section controls (e.g. a toolbar) that does
13
+ // not toggle the section.
14
+ import { ref } from 'vue'
15
+
16
+ const props = defineProps<{
17
+ sections: AccordionSection[]
18
+ }>()
19
+
20
+ const open = ref(new Set(props.sections.map(s => s.id)))
21
+
22
+ function toggle(id: string) {
23
+ const next = new Set(open.value)
24
+ if (next.has(id)) {
25
+ next.delete(id)
26
+ } else {
27
+ next.add(id)
28
+ }
29
+ open.value = next
30
+ }
31
+ </script>
32
+ <template>
33
+ <div class="Accordion">
34
+ <section v-for="section in sections" :key="section.id" class="Accordion__section">
35
+ <header class="Accordion__header">
36
+ <button type="button" class="Accordion__toggle" :class="{ open: open.has(section.id) }" @click="toggle(section.id)"><span class="Accordion__caret">▶</span>{{ section.title }}</button>
37
+ <div class="Accordion__tools">
38
+ <slot name="header" :section="section" />
39
+ </div>
40
+ </header>
41
+ <div v-show="open.has(section.id)" class="Accordion__body">
42
+ <slot :section="section" />
43
+ </div>
44
+ </section>
45
+ </div>
46
+ </template>
47
+ <style lang="scss">
48
+ .Accordion {
49
+ &__section:not(:last-child) {
50
+ border-bottom: 1px solid var(--border-color-light);
51
+ }
52
+ &__header {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: var(--space-2);
56
+ padding: var(--space-1) var(--space-2);
57
+ }
58
+ &__toggle {
59
+ flex-grow: 1;
60
+ display: flex;
61
+ align-items: center;
62
+ gap: var(--space-1);
63
+ text-align: start;
64
+ border: none;
65
+ background: transparent;
66
+ cursor: default;
67
+ font-weight: bold;
68
+ }
69
+ &__caret {
70
+ font-size: 9px;
71
+ line-height: 1;
72
+ transition: transform 0.1s ease;
73
+ .Accordion__toggle.open & {
74
+ transform: rotate(90deg);
75
+ }
76
+ }
77
+ &__tools {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: var(--space-1);
81
+ flex-wrap: wrap;
82
+ }
83
+ }
84
+ </style>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+
4
+ // Interactive button/link for a one-shot async action: label switches between
5
+ // dirty/done/in-progress variants, the element disables itself while the
6
+ // promise is pending, with an optional spinner.
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ promiseMaker: () => Promise<void>
10
+ isDirty?: boolean
11
+ isValid?: boolean
12
+ showSpinner?: boolean
13
+ labelDirty: string
14
+ labelDone?: string
15
+ labelInProgress?: string
16
+ type?: string
17
+ tag?: string
18
+ }>(),
19
+ {
20
+ type: 'button',
21
+ tag: 'button',
22
+ showSpinner: true
23
+ }
24
+ )
25
+ const spinner = ref(false)
26
+ const inProgress = ref(false)
27
+ // this is true when the action can be called e.g. when there is unsaved data (as opposed to when it has just been saved and there is nothing new to save yet)
28
+ const label = computed(() => {
29
+ if (inProgress.value) {
30
+ return props.labelInProgress || props.labelDirty
31
+ }
32
+ if (!props.isDirty) {
33
+ return props.labelDone || props.labelDirty
34
+ }
35
+ return props.labelDirty
36
+ })
37
+ const disabled = computed(() => !props.isValid || !props.isDirty || inProgress.value)
38
+ const run = async () => {
39
+ if (props.isValid) {
40
+ inProgress.value = true
41
+ if (props.showSpinner) {
42
+ spinner.value = true
43
+ }
44
+ try {
45
+ await props.promiseMaker()
46
+ } finally {
47
+ spinner.value = false
48
+ inProgress.value = false
49
+ }
50
+ }
51
+ }
52
+ defineExpose({
53
+ run
54
+ })
55
+ </script>
56
+ <template>
57
+ <component :is="tag" class="AsyncAction" :class="{ disabled, spinner }" :type="tag === 'button' ? type : undefined" :href="tag === 'a' ? '#' : undefined" @click.prevent="run">{{ label }}</component>
58
+ </template>
59
+ <style lang="scss">
60
+ @use './shared' as *;
61
+ .AsyncAction {
62
+ &.spinner::before {
63
+ @include spinner-inline-right(8px);
64
+ }
65
+ }
66
+ </style>