@nxgt/mail-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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/components/alert.vue +75 -0
  4. package/components/avatar-fallback.vue +21 -0
  5. package/components/avatar-group.vue +44 -0
  6. package/components/avatar-image.vue +20 -0
  7. package/components/avatar.vue +29 -0
  8. package/components/badge.vue +47 -0
  9. package/components/banner.vue +57 -0
  10. package/components/breakdown-card.vue +27 -0
  11. package/components/card-content.vue +16 -0
  12. package/components/card-description.vue +16 -0
  13. package/components/card-footer.vue +16 -0
  14. package/components/card-header.vue +33 -0
  15. package/components/card-title.vue +20 -0
  16. package/components/card.vue +30 -0
  17. package/components/chip.vue +41 -0
  18. package/components/code.vue +26 -0
  19. package/components/compare-card.vue +63 -0
  20. package/components/description.vue +24 -0
  21. package/components/entity-header.vue +48 -0
  22. package/components/goal-card.vue +33 -0
  23. package/components/hero.vue +36 -0
  24. package/components/layout.vue +85 -0
  25. package/components/link.vue +19 -0
  26. package/components/list-tile.vue +80 -0
  27. package/components/nx-button.vue +91 -0
  28. package/components/progress.vue +66 -0
  29. package/components/ratio-card.vue +36 -0
  30. package/components/see-also.vue +53 -0
  31. package/components/separator.vue +27 -0
  32. package/components/stat-card.vue +43 -0
  33. package/components/status-indicator.vue +35 -0
  34. package/components/steps-item.vue +48 -0
  35. package/components/steps.vue +31 -0
  36. package/components/summary-data.vue +45 -0
  37. package/components/table-body.vue +11 -0
  38. package/components/table-caption.vue +16 -0
  39. package/components/table-cell.vue +28 -0
  40. package/components/table-empty.vue +23 -0
  41. package/components/table-footer.vue +11 -0
  42. package/components/table-head.vue +25 -0
  43. package/components/table-header.vue +11 -0
  44. package/components/table-row.vue +15 -0
  45. package/components/table.vue +25 -0
  46. package/components/timeline.vue +70 -0
  47. package/components/typography.vue +64 -0
  48. package/components/ui.ts +166 -0
  49. package/dist/catalogues.d.ts +56 -0
  50. package/dist/catalogues.d.ts.map +1 -0
  51. package/dist/index.d.ts +27 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +214 -0
  54. package/dist/index.js.map +13 -0
  55. package/dist/packaged.d.ts +27 -0
  56. package/dist/packaged.d.ts.map +1 -0
  57. package/dist/plugin.d.ts +55 -0
  58. package/dist/plugin.d.ts.map +1 -0
  59. package/dist/theme.d.ts +11 -0
  60. package/dist/theme.d.ts.map +1 -0
  61. package/dist/vue.d.ts +9 -0
  62. package/dist/vue.d.ts.map +1 -0
  63. package/docs/README.md +15 -0
  64. package/docs/guide/components.md +984 -0
  65. package/docs/guide/messages.md +156 -0
  66. package/docs/guide/plugin.md +365 -0
  67. package/docs/guide/theme.md +153 -0
  68. package/docs/roadmap.md +97 -0
  69. package/docs/troubleshooting.md +519 -0
  70. package/package.json +69 -0
  71. package/theme.css +292 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
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,311 @@
1
+ # @nxgt/mail-ui
2
+
3
+ E-mail components for a normal [Maizzle](https://maizzle.com) 6 project, in
4
+ the style of `@nxgt/material-vue`: `<NxLayout>`, `<NxButton>`, `<NxCard>`,
5
+ `<NxAlert>`, `<NxTable>`, `<NxTimeline>`, `<NxStatCard>`… with its variants,
6
+ colours and tokens, rendered with tables and
7
+ inlined styles. One plugin gives every template the components, your brand
8
+ and the theme; a second export gives `@nxgt/mail-i18n` the messages they share.
9
+
10
+ ```ts
11
+ // maizzle.config.ts
12
+ import { defineMailConfig } from '@nxgt/mail-config';
13
+ import { i18n } from '@nxgt/mail-i18n';
14
+ import { ui, uiCatalogues } from '@nxgt/mail-ui';
15
+
16
+ export default defineMailConfig({
17
+ plugins: [
18
+ ui({ brand: { name: 'Acme', url: 'https://acme.example' } }),
19
+ i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] }),
20
+ ],
21
+ });
22
+ ```
23
+
24
+ Your project stays a Maizzle project: `emails/`, `components/`, `public/`,
25
+ `maizzle serve`, `maizzle build`. Maizzle's own components (`<Button>`,
26
+ `<Spacer>`) stay available; ours carry the `Nx` prefix and never shadow them.
27
+
28
+ <img src="https://raw.githubusercontent.com/softistx/nxgt-mail/refs/tags/@nxgt/mail-ui@0.1.0/packages/mail-ui/previews/components-en.png" width="420" alt="An e-mail using the first Nx components: layout, typography, code, buttons, separator, card with badge, summary data and status, alert, banner, link">
29
+
30
+ The components from `NxLayout` to `NxCode` in one e-mail, with the brand
31
+ `Acme` and the default theme —
32
+ [in French](https://raw.githubusercontent.com/softistx/nxgt-mail/refs/tags/@nxgt/mail-ui@0.1.0/packages/mail-ui/previews/components-fr.png).
33
+
34
+ > **0.x.** A minor version may still change the surface; the changelog says how.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ bun add @nxgt/mail-ui @nxgt/mail-config @maizzle/framework @maizzle/tailwindcss vue
40
+ ```
41
+
42
+ Peers, all required:
43
+
44
+ - `@maizzle/framework` (`^6.1.7`) — Maizzle itself.
45
+ - `@nxgt/mail-config` — `ui()` is a plugin for its `defineMailConfig`. It
46
+ needs `@maizzle/tailwindcss` (`^1.5.6`) **as a direct dependency of your
47
+ project**; see [Setup](#setup).
48
+ - `vue` (`^3.5`) — the components are Vue single-file components, and the
49
+ package types `brand` on Vue's template properties.
50
+ - `typescript` (6). Bundler resolution (`"moduleResolution": "bundler"`) is
51
+ what is supported and tested; `nodenext` is out of contract.
52
+
53
+ `@nxgt/mail-i18n` is not a peer: add it (`bun add @nxgt/mail-i18n`) to
54
+ translate your e-mails and to use `uiCatalogues`.
55
+
56
+ One dependency comes with the package: `unplugin-vue-components`, which
57
+ Maizzle already uses. Maizzle resolves no tag in a `.vue` file under
58
+ `node_modules`, so `ui()` resolves them itself for the files a package ships —
59
+ ours, and a package's templates such as `@nxgt/mail-presets`'. See
60
+ [Components from a package](docs/guide/plugin.md#components-from-a-package).
61
+
62
+ ## Setup
63
+
64
+ ```css
65
+ /* what <NxLayout> writes in its <style> — you do not write it */
66
+ @import "@maizzle/tailwindcss";
67
+ ```
68
+
69
+ That import is resolved from your project. Under an isolated install (Bun
70
+ workspaces, pnpm) it fails **silently** unless `@maizzle/tailwindcss` is in
71
+ your own `package.json`, as the `bun add` above makes it: the build succeeds,
72
+ and no style is generated.
73
+
74
+ ```jsonc
75
+ // tsconfig.json — the official starter's include; keep .maizzle/*.d.ts in it
76
+ { "include": ["**/*.vue", ".maizzle/*.d.ts"] }
77
+ ```
78
+
79
+ ```jsonc
80
+ // package.json — the starter's postinstall
81
+ { "scripts": { "postinstall": "maizzle prepare" } }
82
+ ```
83
+
84
+ Each time the config loads (`maizzle prepare`, `serve`, `build`), `ui()`
85
+ writes `.maizzle/nxgt-mail-ui.d.ts`, which loads the type of `brand` for the
86
+ templates. The starter's `tsconfig.json` does not include
87
+ `maizzle.config.ts`, so this file is how the editor learns it. `.maizzle/` is
88
+ in the starter's `.gitignore`. See
89
+ [Editor and type checking](#editor-and-type-checking).
90
+
91
+ ## Exports
92
+
93
+ | Export | What it is |
94
+ | --- | --- |
95
+ | `ui(options)` | The plugin, for `defineMailConfig({ plugins })`: the `Nx*` components, `brand` in every template, the theme |
96
+ | `uiCatalogues` | The shared messages in `en` and `fr`, for `i18n({ catalogues: [uiCatalogues] })` |
97
+ | `UI_CONTEXT` | `'nxgt:mail-ui'`, the Vue `provide` key a component reads the brand and the theme's CSS from |
98
+ | `COMPONENTS_DIR` | The absolute path of the package's `components/` folder |
99
+ | `THEME_FILE` | The absolute path of the package's `theme.css` |
100
+ | `Brand`, `UiOptions`, `UiContext` | The brand, the plugin's options, and what `UI_CONTEXT` provides |
101
+
102
+ | Subpath | What it is |
103
+ | --- | --- |
104
+ | `@nxgt/mail-ui` | The exports above |
105
+ | `@nxgt/mail-ui/theme.css` | The theme: `@theme` tokens for Tailwind 4 |
106
+
107
+ ## Usage
108
+
109
+ ### A template
110
+
111
+ ```vue
112
+ <!-- emails/verify-email.vue -->
113
+ <template>
114
+ <NxLayout :preheader="t('verifyEmail.title')">
115
+ <NxTypography variant="headline-small">{{ t('verifyEmail.title') }}</NxTypography>
116
+ <NxTypography>{{ t('common.greeting', { name: placeholder('name') }) }}</NxTypography>
117
+ <NxButton :href="placeholder('link')">{{ t('verifyEmail.action') }}</NxButton>
118
+ <Spacer height="24px" />
119
+ <NxAlert variant="warning" title="The link expires in 15 minutes." />
120
+ <NxTypography variant="caption">{{ t('common.footer.ignore') }}</NxTypography>
121
+ </NxLayout>
122
+ </template>
123
+ ```
124
+
125
+ `<NxLayout>` is the page: the brand's logo or name at the top, the content on
126
+ a card, and a footer that says why the e-mail came (`common.footer.why`) and
127
+ links the brand. Every other component goes inside it. `t` and `placeholder`
128
+ come from `@nxgt/mail-i18n`; without it, write the text directly. See
129
+ [Components](docs/guide/components.md) for every component, its props and
130
+ its slots.
131
+
132
+ ### The plugin — `ui({ brand, theme })`
133
+
134
+ ```ts
135
+ import { ui } from '@nxgt/mail-ui';
136
+
137
+ ui({
138
+ brand: {
139
+ name: 'Acme',
140
+ url: 'https://acme.example',
141
+ logo: { src: 'https://acme.example/logo.png', width: 96, alt: 'Acme' },
142
+ },
143
+ theme: { 'color-primary': '#0f766e', 'radius-xl': '8px' },
144
+ });
145
+ ```
146
+
147
+ | Option | Type | Default | Effect |
148
+ | --- | --- | --- | --- |
149
+ | `brand.name` | `string` | — (required) | Shown in the header without a logo, and in the footer always |
150
+ | `brand.url` | `string` | none | An absolute `http(s)` URL the header and the footer link to |
151
+ | `brand.logo` | `{ src, width?, alt? }` | none | The header's image: `src` an absolute `http(s)` URL, `width` in pixels (120), `alt` (the brand's name) |
152
+ | `theme` | `Record<string, string>` | `{}` | Tokens of `theme.css` to override, named without their `--` |
153
+
154
+ A wrong option is a bare `TypeError` when the config loads:
155
+ `ui: brand.logo.src must be an absolute http(s) URL — a mail client loads
156
+ nothing relative`. Every template also gets `brand`: `{{ brand.name }}`. See
157
+ [The plugin](docs/guide/plugin.md).
158
+
159
+ ### The theme
160
+
161
+ `theme.css` holds material-vue's light tokens. Maizzle turns each colour into
162
+ a hex value in the built HTML, and material-vue's translucent `bg-primary/15`
163
+ is `bg-primary-15` here, a plain colour, because a mail client drops an
164
+ alpha. Override a colour, and its tints follow:
165
+
166
+ ```ts
167
+ ui({ brand: { name: 'Acme' }, theme: { 'color-primary': '#0f766e' } });
168
+ // <NxButton> is #0f766e; <NxButton variant="tonal"> is #dbeae9, 15% of it over white
169
+ ```
170
+
171
+ See [The theme](docs/guide/theme.md) for every token.
172
+
173
+ ### The shared messages — `uiCatalogues`
174
+
175
+ ```ts
176
+ i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] });
177
+ ```
178
+
179
+ `common.greeting` (`Hello {name},`), `common.footer.why`,
180
+ `common.footer.ignore`, `common.avatarGroup.more`, `common.timeline.empty`,
181
+ `common.metrics.ofTarget`, `common.metrics.thisPeriod`,
182
+ `common.metrics.lastPeriod` and `common.seeAlso`, in `en` and `fr`. Your `locales/<locale>.json`
183
+ overrides any of them, key by key:
184
+
185
+ ```json
186
+ // locales/en.json
187
+ { "common": { "greeting": "Hi {name}," } }
188
+ ```
189
+
190
+ See [Shared messages](docs/guide/messages.md).
191
+
192
+ ### Editor and type checking
193
+
194
+ With the [Setup](#setup) above, Vue's language tools (the **Vue - Official**
195
+ extension in the editor, `vue-tsc` in CI) know `brand` in every template, and
196
+ Maizzle's `.maizzle/prefixed-components.d.ts` gives them the `Nx*` components
197
+ and their props:
198
+
199
+ ```vue
200
+ <!-- emails/welcome.vue -->
201
+ <template>
202
+ <NxTypography>Welcome to {{ brand.nam }}</NxTypography>
203
+ <!-- Property 'nam' does not exist on type 'Brand'. Did you mean 'name'? -->
204
+ </template>
205
+ ```
206
+
207
+ ```sh
208
+ bun add -d vue-tsc
209
+ bunx vue-tsc --noEmit # after maizzle prepare, as in CI
210
+ ```
211
+
212
+ `t`, `locale` and `placeholder` are typed by `@nxgt/mail-i18n`, which writes
213
+ its own file beside this one. See
214
+ [`brand` in templates](docs/guide/plugin.md#brand-in-templates), and
215
+ [`Property 'brand' does not exist`](docs/troubleshooting.md#the-editor-says-property-brand-does-not-exist-in-a-template)
216
+ if the editor does not know `brand`.
217
+
218
+ ### Replacing a component
219
+
220
+ ```vue
221
+ <!-- components/nx-badge.vue — replaces the package's <NxBadge> in every template -->
222
+ <template>
223
+ <span class="rounded-sm bg-primary px-2 text-xs text-primary-foreground"><slot /></span>
224
+ </template>
225
+ ```
226
+
227
+ `components/NxBadge.vue` works as well: Maizzle names a component the same
228
+ from either case.
229
+
230
+ A file in your `components/` named as one of our tags wins over ours — in
231
+ your templates, in ours, and in a package's. A copy of ours must be renamed:
232
+ ours are named without the prefix `ui()` adds, so a copied `badge.vue` is
233
+ `<Badge>` and replaces nothing until it is `nx-badge.vue`. See
234
+ [The plugin](docs/guide/plugin.md#replacing-a-component) and
235
+ [A project's own component does not replace the package's](docs/troubleshooting.md#a-projects-own-component-does-not-replace-the-packages).
236
+
237
+ ## Traps
238
+
239
+ **List `ui()` in `plugins`.** A component used without it fails the build:
240
+ `NxLayout: ui() is not in the plugins of defineMailConfig`.
241
+
242
+ **With `@nxgt/mail-i18n`, give it the shared messages.** `<NxLayout>` calls
243
+ `t('common.footer.why')` once the i18n plugin is listed, and the build fails
244
+ with `calls t('common.footer.why'), which is not a key of the catalogues`
245
+ unless `catalogues: [uiCatalogues]` is passed, or your catalogues hold the
246
+ key.
247
+
248
+ **Another locale writes the `common` keys itself.** `uiCatalogues` has `en`
249
+ and `fr` only: a project in `de` adds its nine keys — `common.greeting`,
250
+ `common.footer.why`, `common.footer.ignore`, `common.avatarGroup.more`,
251
+ `common.timeline.empty`, `common.metrics.ofTarget`,
252
+ `common.metrics.thisPeriod`, `common.metrics.lastPeriod` and
253
+ `common.seeAlso` — to `locales/de.json`.
254
+
255
+ **Icons are slots.** An e-mail has no icon font: pass an `<img>` with an
256
+ absolute URL, or a character, to `#icon`.
257
+
258
+ **Light only.** The layout declares `<meta name="color-scheme" content="light">`;
259
+ there is no dark theme.
260
+
261
+ **Use Maizzle's `<Spacer>` for vertical space**: `<Spacer height="24px" />`.
262
+
263
+ **Never branch on a placeholder** in a component or a template:
264
+ `v-if="link.startsWith('https:')"` is decided on the string `{{ link }}`.
265
+
266
+ ## Type safety, counted
267
+
268
+ **8 plausible mistakes, 8 refused** at compile time, each measured by a
269
+ `@ts-expect-error` in
270
+ [`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-ui/test/types/refusals.ts)
271
+ that fails the typecheck the moment it stops holding:
272
+
273
+ 1. `ui()` without a `brand`.
274
+ 2. A `brand` given as its name alone, rather than `{ name }`.
275
+ 3. A `logo` given as its URL alone, rather than `{ src }`.
276
+ 4. A logo `width` that is not a number of pixels (`'96px'`).
277
+ 5. A `theme` value that is not a CSS string (`{ 'radius-lg': 4 }`).
278
+ 6. A template that assigns `brand`.
279
+ 7. A `logo` without its `src`.
280
+ 8. A `brand.url` that is not a string.
281
+
282
+ The same file holds the calls that must keep compiling: a refusal that refuses
283
+ the correct call is a bug.
284
+
285
+ **2 template mistakes, 2 refused** by `.maizzle/nxgt-mail-ui.d.ts`, each
286
+ measured by a `@vue-expect-error` in
287
+ [`test/fixture/types/refusals.vue`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-ui/test/fixture/types/refusals.vue),
288
+ checked by `vue-tsc` after `maizzle prepare` (`bun run typecheck:templates`):
289
+
290
+ 1. A field the brand does not have (`brand.nmae`).
291
+ 2. `brand.logo.src` without `?.`: the logo is optional.
292
+
293
+ A theme token is a `string`: whether `theme.css` declares it is checked when
294
+ `ui()` is called. A component's props are not in this count: Maizzle declares
295
+ the components in `.maizzle/prefixed-components.d.ts`, and Vue's language
296
+ tools check them in the editor.
297
+
298
+ ## Documentation
299
+
300
+ - [The guides](docs/README.md) — one page per area, with every option,
301
+ component and error.
302
+ - [Troubleshooting](docs/troubleshooting.md) — an error message, its cause and
303
+ its fix.
304
+ - [Roadmap](docs/roadmap.md) — what is next, and what is deliberately not
305
+ planned.
306
+ - [Vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md)
307
+ — the words these pages use, defined once.
308
+
309
+ ## Licence
310
+
311
+ MIT
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, useAttrs, useSlots } from 'vue';
4
+
5
+ /** material-vue's Alert: a tinted block with a thick bar on its left. */
6
+ type Variant =
7
+ | 'primary'
8
+ | 'secondary'
9
+ | 'error'
10
+ | 'success'
11
+ | 'info'
12
+ | 'warning'
13
+ | 'foreground';
14
+
15
+ defineOptions({ inheritAttrs: false });
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ variant?: Variant;
20
+ title?: string;
21
+ description?: string;
22
+ }>(),
23
+ { variant: 'primary' },
24
+ );
25
+
26
+ const VARIANT: Record<Variant, string> = {
27
+ primary: 'border-primary bg-primary-5',
28
+ secondary: 'border-secondary bg-secondary-5',
29
+ error: 'border-error bg-error-5',
30
+ success: 'border-success bg-success-5',
31
+ info: 'border-info bg-info-5',
32
+ warning: 'border-warning bg-warning-5',
33
+ foreground: 'border-foreground bg-muted',
34
+ };
35
+
36
+ /** material-vue colours the icon with the variant: `[&_svg]:text-<variant>`. */
37
+ const ICON: Record<Variant, string> = {
38
+ primary: 'text-primary',
39
+ secondary: 'text-secondary',
40
+ error: 'text-error',
41
+ success: 'text-success',
42
+ info: 'text-info',
43
+ warning: 'text-warning',
44
+ foreground: 'text-foreground',
45
+ };
46
+
47
+ const attrs = useAttrs();
48
+ const slots = useSlots();
49
+ const classes = computed(() =>
50
+ twMerge(
51
+ 'border-0 border-l-8 border-solid p-4',
52
+ VARIANT[props.variant],
53
+ attrs.class as string,
54
+ ),
55
+ );
56
+ </script>
57
+
58
+ <template>
59
+ <table class="mb-4 w-full" role="presentation" cellpadding="0" cellspacing="0">
60
+ <tr>
61
+ <td v-bind="{ ...attrs, class: undefined }" :class="classes">
62
+ <table class="w-full" role="presentation" cellpadding="0" cellspacing="0">
63
+ <tr>
64
+ <td v-if="slots.icon" :class="`w-6 pr-2 align-top ${ICON[props.variant]}`"><slot name="icon" /></td>
65
+ <td class="align-top">
66
+ <p v-if="title || slots.title" class="m-0 mb-1 text-base font-bold text-foreground"><slot name="title">{{ title }}</slot></p>
67
+ <p v-if="description || slots.description" class="m-0 text-sm text-muted-foreground"><slot name="description">{{ description }}</slot></p>
68
+ <slot />
69
+ </td>
70
+ </tr>
71
+ </table>
72
+ </td>
73
+ </tr>
74
+ </table>
75
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, inject, useAttrs } from 'vue';
4
+ import { AVATAR_SIZE } from './ui';
5
+
6
+ /** material-vue's AvatarFallback: initials, centred on a muted ground. */
7
+ defineOptions({ inheritAttrs: false });
8
+
9
+ const attrs = useAttrs();
10
+ const px = inject(AVATAR_SIZE, 32);
11
+ const classes = computed(() =>
12
+ twMerge(
13
+ 'block rounded-full bg-muted text-center text-[12px] font-medium text-foreground',
14
+ attrs.class as string,
15
+ ),
16
+ );
17
+ </script>
18
+
19
+ <template>
20
+ <span v-bind="{ ...attrs, class: undefined }" :class="classes" :style="`width: ${px}px; height: ${px}px; line-height: ${px}px;`"><slot /></span>
21
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, getCurrentInstance, provide, useAttrs, useSlots } from 'vue';
4
+ import { AVATAR_SIZE, sharedMessage, slotComponents } from './ui';
5
+
6
+ /**
7
+ * material-vue's AvatarGroup: its `NxAvatar`s in a row, each ringed with the
8
+ * background, and past `max`, one more reading `+N`. They sit side by side
9
+ * rather than overlapping: Gmail drops the negative margin that stacks them.
10
+ */
11
+ type Size = 'sm' | 'md' | 'lg';
12
+
13
+ defineOptions({ inheritAttrs: false });
14
+
15
+ const props = withDefaults(defineProps<{ max?: number; size?: Size }>(), {
16
+ size: 'md',
17
+ });
18
+
19
+ const SIZE: Record<Size, number> = { sm: 24, md: 32, lg: 40 };
20
+
21
+ const attrs = useAttrs();
22
+ const slots = useSlots();
23
+ provide(AVATAR_SIZE, SIZE[props.size]);
24
+ const avatars = computed(() => slotComponents(slots.default?.()));
25
+ // As material-vue's: no `max`, or one under 1, shows them all.
26
+ const shown = computed(() =>
27
+ props.max === undefined || props.max < 1
28
+ ? avatars.value
29
+ : avatars.value.slice(0, props.max),
30
+ );
31
+ const rest = computed(() => avatars.value.length - shown.value.length);
32
+ const globals: Record<string, unknown> =
33
+ getCurrentInstance()?.appContext.config.globalProperties ?? {};
34
+ const moreLabel = computed(() =>
35
+ sharedMessage(globals, 'common.avatarGroup.more', `${rest.value} more`, {
36
+ count: rest.value,
37
+ }),
38
+ );
39
+ const classes = computed(() => twMerge('mb-4', attrs.class as string));
40
+ </script>
41
+
42
+ <template>
43
+ <div v-bind="{ ...attrs, class: undefined }" :class="classes"><span v-for="(avatar, index) in shown" :key="index" class="mr-1 inline-block rounded-full border-2 border-solid border-background align-middle"><component :is="avatar" /></span><span v-if="rest > 0" class="inline-block rounded-full border-2 border-solid border-background align-middle" :title="moreLabel" role="img" :aria-label="moreLabel"><NxAvatar><NxAvatarFallback>+{{ rest }}</NxAvatarFallback></NxAvatar></span></div>
44
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, inject, useAttrs } from 'vue';
4
+ import { AVATAR_SIZE } from './ui';
5
+
6
+ /** material-vue's AvatarImage: the picture, by absolute URL, cut round. */
7
+ defineOptions({ inheritAttrs: false });
8
+
9
+ defineProps<{ src: string; alt?: string }>();
10
+
11
+ const attrs = useAttrs();
12
+ const px = inject(AVATAR_SIZE, 32);
13
+ const classes = computed(() =>
14
+ twMerge('block rounded-full', attrs.class as string),
15
+ );
16
+ </script>
17
+
18
+ <template>
19
+ <img v-bind="{ ...attrs, class: undefined }" :src="src" :alt="alt ?? ''" :width="px" :height="px" :class="classes">
20
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, inject, provide, useAttrs } from 'vue';
4
+ import { AVATAR_SIZE } from './ui';
5
+
6
+ /**
7
+ * material-vue's Avatar: a round box of `size` pixels (32 by default, or its
8
+ * `NxAvatarGroup`'s) holding an `NxAvatarImage` or an `NxAvatarFallback`.
9
+ * An e-mail cannot fall back when an image fails: give one or the other.
10
+ */
11
+ defineOptions({ inheritAttrs: false });
12
+
13
+ const props = defineProps<{ size?: number }>();
14
+
15
+ const attrs = useAttrs();
16
+ const inherited = inject(AVATAR_SIZE, 32);
17
+ const px = computed(() => props.size ?? inherited);
18
+ provide(AVATAR_SIZE, px.value);
19
+ const classes = computed(() =>
20
+ twMerge(
21
+ 'inline-block overflow-hidden rounded-full align-middle',
22
+ attrs.class as string,
23
+ ),
24
+ );
25
+ </script>
26
+
27
+ <template>
28
+ <span v-bind="{ ...attrs, class: undefined }" :class="classes" :style="`width: ${px}px; height: ${px}px;`"><slot /></span>
29
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, useAttrs } from 'vue';
4
+
5
+ /** material-vue's Badge: a small pill of text. */
6
+ type Variant =
7
+ | 'default'
8
+ | 'secondary'
9
+ | 'destructive'
10
+ | 'error'
11
+ | 'success'
12
+ | 'info'
13
+ | 'warning'
14
+ | 'outline'
15
+ | 'outlined';
16
+
17
+ defineOptions({ inheritAttrs: false });
18
+
19
+ const props = withDefaults(defineProps<{ variant?: Variant }>(), {
20
+ variant: 'default',
21
+ });
22
+
23
+ const VARIANT: Record<Variant, string> = {
24
+ default: 'border-primary bg-primary text-primary-foreground',
25
+ secondary: 'border-secondary bg-secondary text-secondary-foreground',
26
+ destructive: 'border-error bg-error text-white',
27
+ error: 'border-error bg-error text-error-foreground',
28
+ success: 'border-success bg-success text-success-foreground',
29
+ info: 'border-info bg-info text-info-foreground',
30
+ warning: 'border-warning bg-warning text-warning-foreground',
31
+ outline: 'border-border text-foreground',
32
+ outlined: 'border-border text-foreground',
33
+ };
34
+
35
+ const attrs = useAttrs();
36
+ const classes = computed(() =>
37
+ twMerge(
38
+ 'inline-block whitespace-nowrap rounded-full border border-solid px-2 py-0.5 text-xs font-medium',
39
+ VARIANT[props.variant],
40
+ attrs.class as string,
41
+ ),
42
+ );
43
+ </script>
44
+
45
+ <template>
46
+ <span v-bind="{ ...attrs, class: undefined }" :class="classes"><slot /></span>
47
+ </template>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, useAttrs, useSlots } from 'vue';
4
+ import type { Tone } from './ui';
5
+
6
+ /**
7
+ * material-vue's Banner: a status in a tinted, bordered box, with an
8
+ * `action` on the right. Its icon is a slot: an e-mail has no icon font.
9
+ */
10
+ defineOptions({ inheritAttrs: false });
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ tone?: Tone;
15
+ title?: string;
16
+ description?: string;
17
+ }>(),
18
+ { tone: 'info' },
19
+ );
20
+
21
+ const TONE: Record<Tone, string> = {
22
+ info: 'border-info-40 bg-info-10',
23
+ success: 'border-success-40 bg-success-10',
24
+ warning: 'border-warning-40 bg-warning-10',
25
+ error: 'border-error-40 bg-error-10',
26
+ };
27
+
28
+ const attrs = useAttrs();
29
+ const slots = useSlots();
30
+ const classes = computed(() =>
31
+ twMerge(
32
+ 'rounded-md border border-solid px-4 py-3',
33
+ TONE[props.tone],
34
+ attrs.class as string,
35
+ ),
36
+ );
37
+ </script>
38
+
39
+ <template>
40
+ <table class="mb-4 w-full" role="presentation" cellpadding="0" cellspacing="0">
41
+ <tr>
42
+ <td v-bind="{ ...attrs, class: undefined }" :class="classes">
43
+ <table class="w-full" role="presentation" cellpadding="0" cellspacing="0">
44
+ <tr>
45
+ <td v-if="slots.icon" :class="`w-4 pr-3 align-top text-${tone}`"><slot name="icon" /></td>
46
+ <td class="align-top text-foreground">
47
+ <p v-if="title" class="m-0 text-sm font-semibold">{{ title }}</p>
48
+ <p v-if="description" class="m-0 text-sm text-muted-foreground">{{ description }}</p>
49
+ <slot />
50
+ </td>
51
+ <td v-if="slots.action" class="pl-3 text-right align-middle"><slot name="action" /></td>
52
+ </tr>
53
+ </table>
54
+ </td>
55
+ </tr>
56
+ </table>
57
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * material-vue's BreakdownCard: parts of a whole, each a label, its value
4
+ * (its share when none is given) and a thin `NxProgress` of its share.
5
+ */
6
+ defineProps<{
7
+ label: string;
8
+ items: readonly { label: string; value?: string; percent: number }[];
9
+ }>();
10
+ </script>
11
+
12
+ <template>
13
+ <NxCard class="py-4">
14
+ <NxCardContent class="px-4">
15
+ <NxCardDescription class="mt-0">{{ label }}</NxCardDescription>
16
+ <template v-for="(item, position) in items" :key="item.label">
17
+ <table :class="['w-full', position === 0 ? 'mt-4' : 'mt-3']" role="presentation" cellpadding="0" cellspacing="0">
18
+ <tr>
19
+ <td class="align-bottom text-sm font-medium text-foreground">{{ item.label }}</td>
20
+ <td class="whitespace-nowrap pl-3 text-right align-bottom text-sm text-muted-foreground">{{ item.value ?? `${Math.round(item.percent)}%` }}</td>
21
+ </tr>
22
+ </table>
23
+ <NxProgress class="mb-0 mt-1.5" :model-value="item.percent" :height="6" />
24
+ </template>
25
+ </NxCardContent>
26
+ </NxCard>
27
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { twMerge } from '@maizzle/framework';
3
+ import { computed, useAttrs } from 'vue';
4
+
5
+ /** material-vue's CardContent: a row of the card, padded as the others. */
6
+ defineOptions({ inheritAttrs: false });
7
+
8
+ const attrs = useAttrs();
9
+ const classes = computed(() => twMerge('px-6', attrs.class as string));
10
+ </script>
11
+
12
+ <template>
13
+ <tr>
14
+ <td v-bind="{ ...attrs, class: undefined }" :class="classes"><slot /></td>
15
+ </tr>
16
+ </template>