mgv-backoffice 1.40.0 → 1.42.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.
@@ -0,0 +1,10 @@
1
+ import { VNode } from 'vue';
2
+ /** Raw enum names (BLUE_SKY, PROFIT_TARGET) → display text (BLUE SKY). */
3
+ export declare function humanizeEnum(value: string | null | undefined): string;
4
+ /**
5
+ * Rewrites underscores to spaces in slot text so callers can pass raw enum
6
+ * names. Plain text nodes are rewritten and fragments (v-for / v-if) are
7
+ * walked; elements pass through untouched unless their tag is in `tags`, in
8
+ * which case their string children are rewritten too (e.g. `<option>`).
9
+ */
10
+ export declare function humanizeTextVNodes(nodes: VNode[], tags?: readonly string[]): VNode[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.40.0",
3
+ "version": "1.42.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -1,48 +1,48 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { mount, RouterLinkStub } from '@vue/test-utils'
3
- import BaseAuthLayout from './BaseAuthLayout.vue'
4
- import BaseBooleanBadge from './BaseBooleanBadge.vue'
5
-
6
- const global = { stubs: { RouterLink: RouterLinkStub } }
7
-
8
- describe('BaseAuthLayout', () => {
9
- it('renders the default logo, title, content and footer', () => {
10
- const wrapper = mount(BaseAuthLayout, {
11
- props: { title: 'Sign in' },
12
- slots: { default: '<form>form</form>', footer: 'No account? <a>Sign up</a>' },
13
- global,
14
- })
15
- expect(wrapper.find('svg').exists()).toBe(true)
16
- expect(wrapper.find('h1').text()).toBe('Sign in')
17
- expect(wrapper.find('form').text()).toBe('form')
18
- expect(wrapper.text()).toContain('No account? Sign up')
19
- })
20
-
21
- it('lets the logo slot replace the default logo', () => {
22
- const wrapper = mount(BaseAuthLayout, { slots: { logo: '<img alt="brand" />' }, global })
23
- expect(wrapper.find('img').exists()).toBe(true)
24
- expect(wrapper.find('svg').exists()).toBe(false)
25
- })
26
-
27
- it('widens the card with `wide`', () => {
28
- const card = (w: ReturnType<typeof mount>) => w.find('.rounded-xl')
29
- expect(card(mount(BaseAuthLayout, { global })).classes()).toContain('max-w-md')
30
- expect(card(mount(BaseAuthLayout, { props: { wide: true }, global })).classes()).toContain('max-w-3xl')
31
- })
32
- })
33
-
34
- describe('BaseBooleanBadge', () => {
35
- it('renders a green "Yes" pill for true and a neutral "No" pill for false', () => {
36
- const yes = mount(BaseBooleanBadge, { props: { value: true } })
37
- expect(yes.text()).toBe('Yes')
38
- expect(yes.classes()).toContain('bg-emerald-50')
39
- const no = mount(BaseBooleanBadge, { props: { value: false } })
40
- expect(no.text()).toBe('No')
41
- expect(no.classes()).toContain('bg-slate-100')
42
- })
43
-
44
- it('uses custom labels and treats null as false', () => {
45
- const w = mount(BaseBooleanBadge, { props: { value: null, trueLabel: 'Verified', falseLabel: 'Not verified' } })
46
- expect(w.text()).toBe('Not verified')
47
- })
48
- })
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, RouterLinkStub } from '@vue/test-utils'
3
+ import BaseAuthLayout from './BaseAuthLayout.vue'
4
+ import BaseBooleanBadge from './BaseBooleanBadge.vue'
5
+
6
+ const global = { stubs: { RouterLink: RouterLinkStub } }
7
+
8
+ describe('BaseAuthLayout', () => {
9
+ it('renders the default logo, title, content and footer', () => {
10
+ const wrapper = mount(BaseAuthLayout, {
11
+ props: { title: 'Sign in' },
12
+ slots: { default: '<form>form</form>', footer: 'No account? <a>Sign up</a>' },
13
+ global,
14
+ })
15
+ expect(wrapper.find('svg').exists()).toBe(true)
16
+ expect(wrapper.find('h1').text()).toBe('Sign in')
17
+ expect(wrapper.find('form').text()).toBe('form')
18
+ expect(wrapper.text()).toContain('No account? Sign up')
19
+ })
20
+
21
+ it('lets the logo slot replace the default logo', () => {
22
+ const wrapper = mount(BaseAuthLayout, { slots: { logo: '<img alt="brand" />' }, global })
23
+ expect(wrapper.find('img').exists()).toBe(true)
24
+ expect(wrapper.find('svg').exists()).toBe(false)
25
+ })
26
+
27
+ it('widens the card with `wide`', () => {
28
+ const card = (w: ReturnType<typeof mount>) => w.find('.rounded-xl')
29
+ expect(card(mount(BaseAuthLayout, { global })).classes()).toContain('max-w-md')
30
+ expect(card(mount(BaseAuthLayout, { props: { wide: true }, global })).classes()).toContain('max-w-3xl')
31
+ })
32
+ })
33
+
34
+ describe('BaseBooleanBadge', () => {
35
+ it('renders a green "Yes" pill for true and a red "No" pill for false', () => {
36
+ const yes = mount(BaseBooleanBadge, { props: { value: true } })
37
+ expect(yes.text()).toBe('Yes')
38
+ expect(yes.classes()).toContain('bg-emerald-50')
39
+ const no = mount(BaseBooleanBadge, { props: { value: false } })
40
+ expect(no.text()).toBe('No')
41
+ expect(no.classes()).toContain('bg-red-50')
42
+ })
43
+
44
+ it('uses custom labels and treats null as false', () => {
45
+ const w = mount(BaseBooleanBadge, { props: { value: null, trueLabel: 'Verified', falseLabel: 'Not verified' } })
46
+ expect(w.text()).toBe('Not verified')
47
+ })
48
+ })
@@ -5,8 +5,9 @@
5
5
  </template>
6
6
 
7
7
  <script lang="ts" setup>
8
- import { computed, createTextVNode, Text, useSlots } from 'vue'
8
+ import { computed, useSlots } from 'vue'
9
9
  import { ColorsEnums } from "../enums/ColorsEnums";
10
+ import { humanizeTextVNodes } from '../utils/humanize'
10
11
 
11
12
  const slots = useSlots()
12
13
  const hasDefaultSlot = computed(() => !!slots.default)
@@ -14,12 +15,7 @@ const hasDefaultSlot = computed(() => !!slots.default)
14
15
  // Badge labels are often raw enum names (PROFIT_TARGET, RSI_REVERT); render
15
16
  // their underscores as spaces so callers don't have to humanize at every call
16
17
  // site. Only plain text nodes are rewritten — element children pass through.
17
- const BadgeContent = () =>
18
- (slots.default?.() ?? []).map((node) =>
19
- node.type === Text && typeof node.children === 'string'
20
- ? createTextVNode(node.children.replace(/_/g, ' '))
21
- : node
22
- )
18
+ const BadgeContent = () => humanizeTextVNodes(slots.default?.() ?? [])
23
19
 
24
20
  const props = defineProps({
25
21
  color: {
@@ -1,33 +1,32 @@
1
- <template>
2
- <span
3
- class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset"
4
- :class="value
5
- ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/30'
6
- : 'bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-700/50 dark:text-slate-300 dark:ring-slate-400/20'"
7
- >
8
- <CheckCircleIcon v-if="value" class="h-3.5 w-3.5" aria-hidden="true" />
9
- <XCircleIcon v-else class="h-3.5 w-3.5" aria-hidden="true" />
10
- {{ value ? trueLabel : falseLabel }}
11
- </span>
12
- </template>
13
-
14
- <script setup lang="ts">
15
- /**
16
- * Yes/no status pill for boolean columns ("Verified", "Has API", …):
17
- * a green check pill when true, a neutral cross pill when false — so a
18
- * `false` reads as a state, not as an error.
19
- */
20
- import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
21
-
22
- interface Props {
23
- value?: boolean | null
24
- trueLabel?: string
25
- falseLabel?: string
26
- }
27
-
28
- withDefaults(defineProps<Props>(), {
29
- value: false,
30
- trueLabel: 'Yes',
31
- falseLabel: 'No',
32
- })
33
- </script>
1
+ <template>
2
+ <span
3
+ class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset"
4
+ :class="value
5
+ ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/30'
6
+ : 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-400/30'"
7
+ >
8
+ <CheckCircleIcon v-if="value" class="h-3.5 w-3.5" aria-hidden="true" />
9
+ <XCircleIcon v-else class="h-3.5 w-3.5" aria-hidden="true" />
10
+ {{ value ? trueLabel : falseLabel }}
11
+ </span>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ /**
16
+ * Yes/no status pill for boolean columns ("Verified", "Has API", …):
17
+ * a green check pill when true, a red cross pill when false.
18
+ */
19
+ import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
20
+
21
+ interface Props {
22
+ value?: boolean | null
23
+ trueLabel?: string
24
+ falseLabel?: string
25
+ }
26
+
27
+ withDefaults(defineProps<Props>(), {
28
+ value: false,
29
+ trueLabel: 'Yes',
30
+ falseLabel: 'No',
31
+ })
32
+ </script>
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseCheckbox from './BaseCheckbox.vue'
4
+
5
+ describe('BaseCheckbox', () => {
6
+ it('is rounded and emerald when checked', () => {
7
+ const input = mount(BaseCheckbox).find('input')
8
+ expect(input.classes()).toEqual(expect.arrayContaining(['rounded-md', 'text-emerald-600', 'accent-emerald-600']))
9
+ })
10
+
11
+ it('reflects modelValue and emits the new value on change', async () => {
12
+ const wrapper = mount(BaseCheckbox, { props: { modelValue: false } })
13
+ const input = wrapper.find('input')
14
+ expect((input.element as HTMLInputElement).checked).toBe(false)
15
+ await input.setValue(true)
16
+ expect(wrapper.emitted('update:modelValue')).toEqual([[true]])
17
+ expect(wrapper.emitted('change')).toEqual([[true]])
18
+ })
19
+
20
+ it('renders the label and forwards attrs to the input', () => {
21
+ const wrapper = mount(BaseCheckbox, { props: { label: 'Remember me' }, attrs: { id: 'remember' } })
22
+ expect(wrapper.text()).toBe('Remember me')
23
+ expect(wrapper.find('input').attributes('id')).toBe('remember')
24
+ })
25
+
26
+ it('can be disabled', () => {
27
+ const wrapper = mount(BaseCheckbox, { props: { disabled: true } })
28
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
29
+ })
30
+ })
@@ -0,0 +1,51 @@
1
+ <template>
2
+ <label
3
+ class="inline-flex items-center gap-2 select-none"
4
+ :class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
5
+ >
6
+ <input
7
+ v-bind="$attrs"
8
+ type="checkbox"
9
+ :checked="modelValue"
10
+ :disabled="disabled"
11
+ class="h-5 w-5 shrink-0 cursor-[inherit] rounded-md border-slate-300 bg-white text-emerald-600 accent-emerald-600 transition-colors focus:ring-2 focus:ring-emerald-500/40 focus:ring-offset-0 dark:border-slate-600 dark:bg-slate-900 dark:checked:bg-emerald-600"
12
+ @change="onChange"
13
+ />
14
+ <span v-if="label || $slots.default" class="text-sm text-slate-700 dark:text-slate-200">
15
+ <slot>{{ label }}</slot>
16
+ </span>
17
+ </label>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ /**
22
+ * Rounded checkbox, emerald when checked (both a forms-plugin fill via
23
+ * `text-emerald-600` and native `accent-color`, so it looks the same with or
24
+ * without @tailwindcss/forms / Flowbite). `v-model` is the boolean; `change`
25
+ * fires with the new value. Attrs (id, name, …) go to the <input>.
26
+ */
27
+ defineOptions({ inheritAttrs: false })
28
+
29
+ interface Props {
30
+ modelValue?: boolean
31
+ label?: string
32
+ disabled?: boolean
33
+ }
34
+
35
+ withDefaults(defineProps<Props>(), {
36
+ modelValue: false,
37
+ label: '',
38
+ disabled: false,
39
+ })
40
+
41
+ const emit = defineEmits<{
42
+ (e: 'update:modelValue', value: boolean): void
43
+ (e: 'change', value: boolean): void
44
+ }>()
45
+
46
+ function onChange(e: Event) {
47
+ const checked = (e.target as HTMLInputElement).checked
48
+ emit('update:modelValue', checked)
49
+ emit('change', checked)
50
+ }
51
+ </script>
@@ -35,3 +35,23 @@ describe('BaseSelect', () => {
35
35
  expect(wrapper.findAll('option').map((o) => o.text())).toEqual(['A', 'B'])
36
36
  })
37
37
  })
38
+
39
+ describe('BaseSelect humanize', () => {
40
+ const enumSlots = {
41
+ default: '<option value="BLUE_SKY">BLUE_SKY</option><option v-for="o in [\'TICK_TOCK\']" :key="o" :value="o">{{ o }}</option>',
42
+ }
43
+
44
+ it('shows underscores as spaces but keeps raw option values', async () => {
45
+ const wrapper = mount(BaseSelect, { slots: enumSlots })
46
+ const options = wrapper.findAll('option')
47
+ expect(options.map((o) => o.text())).toEqual(['BLUE SKY', 'TICK TOCK'])
48
+ expect(options.map((o) => o.element.value)).toEqual(['BLUE_SKY', 'TICK_TOCK'])
49
+ await wrapper.find('select').setValue('TICK_TOCK')
50
+ expect(wrapper.emitted('update:modelValue')).toEqual([['TICK_TOCK']])
51
+ })
52
+
53
+ it('can be turned off with humanize=false', () => {
54
+ const wrapper = mount(BaseSelect, { props: { humanize: false }, slots: enumSlots })
55
+ expect(wrapper.findAll('option').map((o) => o.text())).toEqual(['BLUE_SKY', 'TICK_TOCK'])
56
+ })
57
+ })
@@ -3,11 +3,11 @@
3
3
  <select
4
4
  v-bind="$attrs"
5
5
  :value="modelValue"
6
- class="w-full appearance-none bg-none rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm cursor-pointer transition-colors hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
6
+ class="base-select w-full appearance-none bg-none rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm cursor-pointer transition-colors hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
7
7
  :class="size === 'md' ? 'py-2 pl-3 pr-9' : 'py-1.5 pl-2.5 pr-8'"
8
8
  @change="onChange"
9
9
  >
10
- <slot />
10
+ <SlotContent />
11
11
  </select>
12
12
  <ChevronDownIcon
13
13
  class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 dark:text-slate-400"
@@ -25,7 +25,9 @@
25
25
  * through. Same `dark:`-variant note as BaseInput.
26
26
  *
27
27
  */
28
+ import { useSlots } from 'vue'
28
29
  import { ChevronDownIcon } from '@heroicons/vue/24/outline'
30
+ import { humanizeTextVNodes } from '../utils/humanize'
29
31
 
30
32
  // Attrs (id, name, disabled, extra classes) go to the <select>, not the
31
33
  // chevron wrapper. `bg-none` drops any arrow a forms reset (e.g. Flowbite /
@@ -43,14 +45,25 @@ interface Props {
43
45
  size?: 'sm' | 'md'
44
46
  /** Full-width (w-full). Set false for inline selects. */
45
47
  block?: boolean
48
+ /** Render underscores in option text as spaces (BLUE_SKY → BLUE SKY). Values are untouched. */
49
+ humanize?: boolean
46
50
  }
47
51
 
48
- withDefaults(defineProps<Props>(), {
52
+ const props = withDefaults(defineProps<Props>(), {
49
53
  modelValue: undefined,
50
54
  size: 'sm',
51
55
  block: true,
56
+ humanize: true,
52
57
  })
53
58
 
59
+ // Options are often raw enum names; only their visible text is rewritten, the
60
+ // `value` attribute (what v-model receives) stays the raw enum.
61
+ const slots = useSlots()
62
+ const SlotContent = () => {
63
+ const nodes = slots.default?.() ?? []
64
+ return props.humanize ? humanizeTextVNodes(nodes, ['option', 'optgroup']) : nodes
65
+ }
66
+
54
67
  const emit = defineEmits<{
55
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
69
  (e: 'update:modelValue', value: any): void
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, RouterLinkStub } from '@vue/test-utils'
3
+ import BaseSidebarUser from './BaseSidebarUser.vue'
4
+
5
+ const global = { stubs: { RouterLink: RouterLinkStub } }
6
+
7
+ describe('BaseSidebarUser', () => {
8
+ it('shows initials, name and email', () => {
9
+ const wrapper = mount(BaseSidebarUser, { props: { name: 'john.doe', email: 'j@d.test' }, global })
10
+ expect(wrapper.text()).toContain('JD')
11
+ expect(wrapper.text()).toContain('john.doe')
12
+ expect(wrapper.text()).toContain('j@d.test')
13
+ })
14
+
15
+ it('links to the profile when `to` is set', () => {
16
+ const wrapper = mount(BaseSidebarUser, { props: { name: 'test', to: { name: 'Profile' } }, global })
17
+ expect(wrapper.findComponent(RouterLinkStub).props('to')).toEqual({ name: 'Profile' })
18
+ })
19
+
20
+ it('emits logout from the button, and can hide it', async () => {
21
+ const wrapper = mount(BaseSidebarUser, { props: { name: 'test' }, global })
22
+ await wrapper.find('button').trigger('click')
23
+ expect(wrapper.emitted('logout')).toHaveLength(1)
24
+ expect(mount(BaseSidebarUser, { props: { name: 'test', showLogout: false }, global }).find('button').exists()).toBe(false)
25
+ })
26
+ })
@@ -0,0 +1,75 @@
1
+ <template>
2
+ <div class="flex flex-col gap-2 text-left">
3
+ <component
4
+ :is="to ? RouterLink : 'div'"
5
+ v-bind="to ? { to } : {}"
6
+ class="flex items-center gap-3 rounded-lg p-2 transition-colors"
7
+ :class="to ? 'hover:bg-slate-100 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500' : ''"
8
+ :title="to ? profileLabel : undefined"
9
+ >
10
+ <span
11
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-sm font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
12
+ aria-hidden="true"
13
+ >{{ initials }}</span>
14
+ <span class="min-w-0">
15
+ <span class="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">{{ name }}</span>
16
+ <span v-if="email" class="block truncate text-xs text-slate-500 dark:text-slate-400">{{ email }}</span>
17
+ </span>
18
+ </component>
19
+ <BaseButton
20
+ v-if="showLogout"
21
+ class="w-full justify-center"
22
+ :description="logoutLabel"
23
+ :color="BaseButtonEnum.RED"
24
+ :size="BaseButtonSizeEnum.SMALL"
25
+ outline
26
+ icon-left
27
+ @click="emit('logout')"
28
+ >
29
+ <ArrowRightStartOnRectangleIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
30
+ </BaseButton>
31
+ </div>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ /**
36
+ * Signed-in user block for the sidebar footer (BaseSidebar `footer` slot):
37
+ * initials avatar + name + email, linking to the profile page when `to` is
38
+ * set, and a Logout button that emits `logout` (the app clears its session).
39
+ */
40
+ import { computed } from 'vue'
41
+ import { RouterLink, type RouteLocationRaw } from 'vue-router'
42
+ import { ArrowRightStartOnRectangleIcon } from '@heroicons/vue/24/outline'
43
+ import BaseButton from './BaseButton.vue'
44
+ import { BaseButtonEnum } from '../enums/BaseButtonEnum'
45
+ import { BaseButtonSizeEnum } from '../enums/BaseButtonSizeEnum'
46
+
47
+ interface Props {
48
+ name: string
49
+ email?: string
50
+ /** Profile route; makes the name/email block a link. */
51
+ to?: RouteLocationRaw
52
+ showLogout?: boolean
53
+ logoutLabel?: string
54
+ profileLabel?: string
55
+ }
56
+
57
+ const props = withDefaults(defineProps<Props>(), {
58
+ email: '',
59
+ to: undefined,
60
+ showLogout: true,
61
+ logoutLabel: 'Logout',
62
+ profileLabel: 'View profile',
63
+ })
64
+
65
+ const emit = defineEmits<{ (e: 'logout'): void }>()
66
+
67
+ const initials = computed(() =>
68
+ (props.name || '?')
69
+ .split(/[\s._-]+/)
70
+ .filter(Boolean)
71
+ .slice(0, 2)
72
+ .map((p) => p[0]!.toUpperCase())
73
+ .join(''),
74
+ )
75
+ </script>
@@ -1,76 +1,76 @@
1
- <template>
2
- <!--
3
- Animated loading spinner — a spinning ring with a neutral track and a
4
- coloured leading arc.
5
-
6
- Props:
7
- • size — diameter + ring thickness. 'sm' (16px) is the default and
8
- reproduces the original spinner; 'md' (24px), 'lg' (32px) and
9
- 'xl' (48px) step up for full-page / empty-state loaders.
10
- • color — the colour of the spinning arc (`border-t-*`). The track
11
- stays neutral gray. Defaults to 'blue' to match the original.
12
-
13
- With no props it renders exactly the legacy spinner, so existing
14
- `<BaseSpinner />` call sites are unaffected.
15
- -->
16
- <div
17
- class="border-gray-200 rounded-full animate-spin"
18
- :class="[sizeClass, colorClass]"
19
- role="status"
20
- aria-label="Loading"
21
- ></div>
22
- </template>
23
-
24
- <script setup lang="ts">
25
- import { computed } from 'vue'
26
-
27
- type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
28
- type SpinnerColor =
29
- | 'blue'
30
- | 'emerald'
31
- | 'sky'
32
- | 'indigo'
33
- | 'teal'
34
- | 'purple'
35
- | 'red'
36
- | 'amber'
37
-
38
- const props = withDefaults(
39
- defineProps<{
40
- size?: SpinnerSize
41
- color?: SpinnerColor
42
- }>(),
43
- {
44
- size: 'sm',
45
- color: 'blue',
46
- },
47
- )
48
-
49
- // Diameter pairs with a proportional ring thickness. Full literal strings
50
- // so Tailwind detects them at build time.
51
- const sizeClass = computed(() => {
52
- const sizes: Record<SpinnerSize, string> = {
53
- sm: 'w-4 h-4 border-2',
54
- md: 'w-6 h-6 border-2',
55
- lg: 'w-8 h-8 border-4',
56
- xl: 'w-12 h-12 border-4',
57
- }
58
- return sizes[props.size]
59
- })
60
-
61
- // Only the leading arc (`border-t-*`) is tinted; the rest of the ring is
62
- // the neutral gray track from the template.
63
- const colorClass = computed(() => {
64
- const colors: Record<SpinnerColor, string> = {
65
- blue: 'border-t-blue-500',
66
- emerald: 'border-t-emerald-500',
67
- sky: 'border-t-sky-500',
68
- indigo: 'border-t-indigo-500',
69
- teal: 'border-t-teal-500',
70
- purple: 'border-t-purple-500',
71
- red: 'border-t-red-500',
72
- amber: 'border-t-amber-500',
73
- }
74
- return colors[props.color]
75
- })
76
- </script>
1
+ <template>
2
+ <div
3
+ class="border-gray-200 dark:border-slate-700 rounded-full animate-spin"
4
+ :class="[sizeClass, colorClass]"
5
+ role="status"
6
+ aria-label="Loading"
7
+ ></div>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ /**
12
+ * Animated loading spinner — a spinning ring with a neutral track and a
13
+ * coloured leading arc.
14
+ *
15
+ * Props:
16
+ * • size — diameter + ring thickness. 'sm' (16px) is the default and
17
+ * reproduces the original spinner; 'md' (24px), 'lg' (32px) and
18
+ * 'xl' (48px) step up for full-page / empty-state loaders.
19
+ * • color — the colour of the spinning arc (`border-t-*`). The track
20
+ * stays neutral gray. Defaults to 'emerald' (the brand green).
21
+ *
22
+ * With no props it renders the small (16px) emerald spinner.
23
+ *
24
+ */
25
+ import { computed } from 'vue'
26
+
27
+ type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
28
+ type SpinnerColor =
29
+ | 'blue'
30
+ | 'emerald'
31
+ | 'sky'
32
+ | 'indigo'
33
+ | 'teal'
34
+ | 'purple'
35
+ | 'red'
36
+ | 'amber'
37
+
38
+ const props = withDefaults(
39
+ defineProps<{
40
+ size?: SpinnerSize
41
+ color?: SpinnerColor
42
+ }>(),
43
+ {
44
+ size: 'sm',
45
+ color: 'emerald',
46
+ },
47
+ )
48
+
49
+ // Diameter pairs with a proportional ring thickness. Full literal strings
50
+ // so Tailwind detects them at build time.
51
+ const sizeClass = computed(() => {
52
+ const sizes: Record<SpinnerSize, string> = {
53
+ sm: 'w-4 h-4 border-2',
54
+ md: 'w-6 h-6 border-2',
55
+ lg: 'w-8 h-8 border-4',
56
+ xl: 'w-12 h-12 border-4',
57
+ }
58
+ return sizes[props.size]
59
+ })
60
+
61
+ // Only the leading arc (`border-t-*`) is tinted; the rest of the ring is
62
+ // the neutral gray track from the template.
63
+ const colorClass = computed(() => {
64
+ const colors: Record<SpinnerColor, string> = {
65
+ blue: 'border-t-blue-500',
66
+ emerald: 'border-t-emerald-500',
67
+ sky: 'border-t-sky-500',
68
+ indigo: 'border-t-indigo-500',
69
+ teal: 'border-t-teal-500',
70
+ purple: 'border-t-purple-500',
71
+ red: 'border-t-red-500',
72
+ amber: 'border-t-amber-500',
73
+ }
74
+ return colors[props.color]
75
+ })
76
+ </script>
package/src/index.ts CHANGED
@@ -61,6 +61,8 @@ export { default as BaseCard } from './components/BaseCard.vue'
61
61
  export { default as BaseField } from './components/BaseField.vue'
62
62
  export { default as BaseStatCard } from './components/BaseStatCard.vue'
63
63
  export { default as BaseToggle } from './components/BaseToggle.vue'
64
+ export { default as BaseCheckbox } from './components/BaseCheckbox.vue'
65
+ export { default as BaseSidebarUser } from './components/BaseSidebarUser.vue'
64
66
  export { default as BaseAuthLayout } from './components/BaseAuthLayout.vue'
65
67
  export { default as BaseBooleanBadge } from './components/BaseBooleanBadge.vue'
66
68
  export { default as BaseDetailList } from './components/BaseDetailList.vue'
@@ -147,5 +149,6 @@ export {
147
149
  isValidBase64,
148
150
  } from './utils/validate'
149
151
  export { buildSpecParams, firstInvalidNumericSpec } from './utils/specForm'
152
+ export { humanizeEnum, humanizeTextVNodes } from './utils/humanize'
150
153
  export { computePnL } from './utils/pnl'
151
154
  export type { PnL, PnLInputs } from './utils/pnl'