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.
- package/README.md +34 -4
- package/dist/components/BaseCheckbox.vue.d.ts +28 -0
- package/dist/components/BaseSelect.vue.d.ts +4 -11
- package/dist/components/BaseSidebarUser.vue.d.ts +23 -0
- package/dist/index.d.ts +3 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1105 -995
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/humanize.d.ts +10 -0
- package/package.json +1 -1
- package/src/components/BaseAuthLayout.test.ts +48 -48
- package/src/components/BaseBadge.vue +3 -7
- package/src/components/BaseBooleanBadge.vue +32 -33
- package/src/components/BaseCheckbox.test.ts +30 -0
- package/src/components/BaseCheckbox.vue +51 -0
- package/src/components/BaseSelect.test.ts +20 -0
- package/src/components/BaseSelect.vue +16 -3
- package/src/components/BaseSidebarUser.test.ts +26 -0
- package/src/components/BaseSidebarUser.vue +75 -0
- package/src/components/BaseSpinner.vue +76 -76
- package/src/index.ts +3 -0
- package/src/style.css +71 -0
- package/src/utils/humanize.test.ts +35 -0
- package/src/utils/humanize.ts +35 -0
|
@@ -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,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
|
|
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-
|
|
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,
|
|
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-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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: '
|
|
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'
|