@greendrake/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +327 -0
- package/package.json +52 -0
- package/src/Accordion.vue +84 -0
- package/src/AsyncAction.vue +66 -0
- package/src/AsyncSearch.vue +262 -0
- package/src/AsyncSearchResults.vue +74 -0
- package/src/BottomNav.vue +125 -0
- package/src/Checkbox.vue +112 -0
- package/src/CircularProgress.vue +57 -0
- package/src/Copy.vue +51 -0
- package/src/CurrencyInput.vue +143 -0
- package/src/Email.vue +17 -0
- package/src/Field.vue +54 -0
- package/src/FieldTip.scss +76 -0
- package/src/FieldTip.ts +57 -0
- package/src/FixAndroidTyping.ts +13 -0
- package/src/Form.ts +124 -0
- package/src/Form.vue +78 -0
- package/src/Gallery.vue +174 -0
- package/src/Loading.vue +32 -0
- package/src/MenuButton.vue +95 -0
- package/src/Modal.vue +365 -0
- package/src/ModalOnClick.vue +73 -0
- package/src/OptionSwitch.vue +110 -0
- package/src/Password.vue +85 -0
- package/src/RowSelect.ts +211 -0
- package/src/SearchField.vue +124 -0
- package/src/SegmentedControl.vue +85 -0
- package/src/SelectBox.vue +468 -0
- package/src/Splash.vue +36 -0
- package/src/TabBar.vue +158 -0
- package/src/TextField.vue +116 -0
- package/src/TheWrap.vue +30 -0
- package/src/ThumbnailGrid.vue +166 -0
- package/src/TreeView.vue +308 -0
- package/src/_shared.scss +22 -0
- package/src/copyTextToClipboard.ts +3 -0
- package/src/downsizeImage.ts +78 -0
- package/src/inModal.ts +8 -0
- package/src/main.ts +78 -0
- package/src/messages.ts +39 -0
- package/src/modalStack.ts +181 -0
- package/src/styles.d.ts +6 -0
- package/src/toast/ToastShell.vue +70 -0
- package/src/toast/showToast.ts +81 -0
- package/src/touchSlop.ts +6 -0
- package/src/useSelection.ts +99 -0
- package/src/validationScope.ts +6 -0
package/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>
|