Merge branch 'button' into 'main'

Button

See merge request cellule-financiere-pmo/design-system/visua-vue!1
This commit is contained in:
Paul Valerie GOMA 2025-07-19 23:41:09 +00:00
commit 772bf1418f
17 changed files with 1612 additions and 21 deletions

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ coverage
*.tsbuildinfo
.npmrc
template

View File

@ -10,6 +10,11 @@ cache:
- node_modules/
before_script:
- echo "@cellule-financiere-pmo:registry=https://gitlab.com/api/v4/projects/71595796/packages/npm/" > .npmrc
- echo "//gitlab.com/api/v4/projects/71595796/packages/npm/:username=${NPM_DEPLOY_USER}" >> .npmrc
- echo "//gitlab.com/api/v4/projects/71595796/packages/npm/:_password=$(echo -n ${NPM_DEPLOY_TOKEN} | base64)" >> .npmrc
- echo "//gitlab.com/api/v4/projects/71595796/packages/npm/:email=ci@example.com" >> .npmrc
- echo "//gitlab.com/api/v4/projects/71595796/packages/npm/:always-auth=true" >> .npmrc
- npm ci
unit-tests:
@ -21,14 +26,11 @@ unit-tests:
- coverage/
expire_in: 1 month
only:
- branches
- merge_requests
publish-npm:
stage: deploy
script:
- echo "@cellule-financiere-pmo:registry=https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/" > .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}" >> .npmrc
- npm publish
only:
- tags

1101
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,8 @@
"test:unit": "vitest run --coverage"
},
"dependencies": {
"@cellule-financiere-pmo/visua": "^1.1.0",
"@cellule-financiere-pmo/visua": "1.1.3",
"jsdom": "^26.1.0",
"primevue": "^4.3.6",
"vite-plugin-inspect": "^11.3.0",
"vue": "^3.5.17"
@ -22,6 +23,7 @@
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.15.32",
"@vitejs/plugin-vue": "^6.0.0",
"@vitest/coverage-v8": "^3.2.4",
"@vue/eslint-config-typescript": "^14.5.1",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
@ -32,6 +34,7 @@
"typescript": "~5.8.0",
"vite": "^7.0.0",
"vite-plugin-vue-devtools": "^7.7.7",
"vitest": "^3.2.4",
"vue-tsc": "^2.2.10"
},
"description": "**Current version: v0.0.0**",

View File

@ -0,0 +1,8 @@
<script setup lang="ts">
import VButtonView from '../template/VButtonView.vue'
</script>
<template>
<VButtonView/>
</template>

View File

@ -1,6 +1,7 @@
/* Adding styles */
@import '../../../node_modules/@cellule-financiere-pmo/visua/output/variables.css';
@import './global.css';
@import '@visua/variables.css';
@import './style/global.css';
@import './style/primevue-configuration.css';
/* Basic setup */
html {

1
src/assets/main.css Normal file
View File

@ -0,0 +1 @@
@import './base.css';

View File

@ -0,0 +1 @@
@import './primevue-style/button.css';

View File

@ -0,0 +1,67 @@
:root{
/* global style */
--p-button-border-radius: 0px;
--p-button-gap: 0.5rem;
--p-button-label-font-weight: var(--text-body-MD-standard-text-Regular-weight);
/* size: normal */
--p-button-padding-x: 1rem;
--p-button-padding-y: 0.5rem;
--p-button-icon-only-width: 2.5rem;
/* size: small */
--p-button-sm-padding-x: 0.75rem;
--p-button-sm-padding-y: 0.25rem;
--p-button-sm-font-size: var(--text-body-SM-detail-text-Regular-size);
--p-button-sm-icon-only-width: 2rem;
/* size: large */
--p-button-lg-padding-x: 1.5rem;
--p-button-lg-padding-y: 0.625rem;
--p-button-lg-font-size: var(--text-body-LG-article-text-Medium-size);
--p-button-lg-icon-only-width: 3rem;
/* variant: primary or default */
--p-button-primary-background: var(--background-action-high-blue-france);
--p-button-primary-hover-background: var(--background-action-high-blue-france-hover);
--p-button-primary-active-background: var(--background-action-high-blue-france-active);
--p-button-primary-border-color: transparent;
--p-button-primary-hover-border-color: transparent;
--p-button-primary-active-border-color: transparent;
--p-button-primary-color: var(--text-inverted-blue-france);
--p-button-primary-hover-color: var(--text-inverted-blue-france);
--p-button-primary-active-color: var(--text-inverted-blue-france);
/* variant: secondary */
--p-button-secondary-background: var(--background-transparent);
--p-button-secondary-hover-background: var(--background-transparent-hover);
--p-button-secondary-active-background: var(--background-transparent-active);
--p-button-secondary-border-color: var(--border-action-high-blue-france);
--p-button-secondary-hover-border-color: var(--border-action-high-blue-france);
--p-button-secondary-active-border-color: var(--border-action-high-blue-france);
--p-button-outlined-secondary-hover-background: var(--background-transparent-hover);
--p-button-outlined-secondary-active-background: var(--background-transparent-active);
--p-button-outlined-secondary-border-color: var(--border-action-high-blue-france);
--p-button-outlined-secondary-color: var(--text-action-high-blue-france);
/* variant: tertiary */
--p-button-outlined-primary-hover-background: var(--background-transparent-hover);
--p-button-outlined-primary-active-background: var(--background-transparent-active);
--p-button-outlined-primary-border-color: var(--border-default-grey);
--p-button-outlined-primary-color: var(--text-action-high-blue-france);
/* variant: no-outline */
--p-button-text-primary-hover-background: var(--background-transparent-hover);
--p-button-text-primary-active-background: var(--background-transparent-active);
--p-button-text-primary-color: var(--text-action-high-blue-france);
/* variant: danger */
--p-button-danger-background: var(--primary-color-425-red-marianne-default);
--p-button-danger-hover-background: var(--primary-color-425-red-marianne-hover);
--p-button-danger-active-background: var(--primary-color-425-red-marianne-active);
--p-button-danger-border-color: transparent;
--p-button-danger-hover-border-color: transparent;
--p-button-danger-active-border-color: transparent;
--p-button-danger-color: var(--text-inverted-blue-france);
--p-button-danger-hover-color: var(--text-inverted-blue-france);
--p-button-danger-active-color: var(--text-inverted-blue-france);
/* focus */
--p-button-focus-ring-width: var(--focus-width);
--p-button-focus-ring-style: var(--focus-style);
--p-button-focus-ring-offset: var(--focus-offset);
--p-button-primary-focus-ring-color: var(--focus-color);
--p-button-secondary-focus-ring-color: var(--focus-color);
--p-button-danger-focus-ring-color: var(--focus-color);
}

View File

@ -0,0 +1,74 @@
import type { ButtonHTMLAttributes } from "vue"
/**
* Interface representing the properties of a single button component.
*/
export default interface IVButton {
/** Whether the button is disabled */
disabled?: boolean;
/** Text label displayed on the button */
label?: string;
/** Applies the secondary button style */
secondary?: boolean;
/** Applies the tertiary button style */
tertiary?: boolean;
/** Displays the icon on the right side of the button */
iconRight?: boolean;
/** Displays only the icon without any label */
iconOnly?: boolean;
/** Removes the default outline style */
noOutline?: boolean;
/** Applies a danger style to the button */
danger?: boolean;
/** Size of the button */
size?: 'sm' | 'small' | 'lg' | 'large' | 'md' | 'medium' | '' | undefined;
/** Name of the icon to display */
icon?: string;
/** Click event handler */
onClick?: ($event: MouseEvent) => void;
/** Tooltip or accessibility title for the button (required) */
title: string;
}
/**
* Interface representing a group of buttons with layout and style options.
*/
export default interface IVButtonGroup {
/**
* Array of buttons to display in the group.
* Each button can include standard HTML button attributes.
*/
buttons?: (IVButton & ButtonHTMLAttributes)[];
/** Reverses the order of the buttons */
reverse?: boolean;
/** Makes all buttons in the group the same width */
equisized?: boolean;
/** Aligns icons to the right for all buttons */
iconRight?: boolean;
/** Alignment of the button group */
align?: 'right' | 'center' | '' | undefined;
/**
* Controls when the layout should switch to inline.
* Can be based on screen size or always/never.
*/
inlineLayoutWhen?: 'always' | 'never' | 'sm' | 'small' | 'lg' | 'large' | 'md' | 'medium' | '' | true | undefined;
/** Size of all buttons in the group */
size?: 'sm' | 'small' | 'lg' | 'large' | 'md' | 'medium' | '' | undefined;
}

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import Button from 'primevue/button';
import type IVButton from './IVButton.type';
import { computed } from 'vue';
import styles from '@visua/typography.module.css';
// Props configuration with default values
const props = withDefaults(defineProps<IVButton>(), {
disabled: false,
label: undefined,
secondary: false,
tertiary: false,
iconRight: false,
iconOnly: false,
noOutline: false,
danger: false,
size: 'md',
icon: '',
onClick: () => undefined,
})
// Button size class computed
const size = computed(() => {
if (['sm', 'small'].includes(props.size)) return 'small';
else if (['md', 'medium', '', undefined].includes(props.size)) return undefined;
else if (['lg', 'large'].includes(props.size)) return 'large';
else return undefined;
})
// Icon position computed
const iconPos = computed<string>(() => {
return props.iconRight ? 'right': 'left'
})
// Button variant computed
const variant = computed(() => {
if(props.noOutline) return 'text';
else if (props.secondary || props.tertiary) return 'outlined';
else return undefined;
})
// Button severity computed
const severity = computed(() => {
if(props.secondary) return 'secondary';
else if(props.danger) return 'danger';
else return undefined
});
// Button font computed
const font = computed(() => {
switch (size.value) {
case 'large': return styles['text-body-LG-article-text-Regular'];
case 'small': return styles['text-body-SM-detail-text-Regular'];
default: return styles['text-body-MD-standard-text-Regular'];
}
})
</script>
<template>
<Button
:label="props.iconOnly ? undefined : props.label"
:variant="variant"
:severity="severity"
:icon="props.icon"
:size="size"
:class="['p-button', font]"
v-bind="$attrs"
:disabled="props.disabled"
:aria-disabled="props.disabled"
:icon-pos="iconPos"
:onclick="props.onClick"
:title="props.title"
role="button"
:aria-label="props.label"
>
</Button>
</template>
<style scoped>
/* disable state */
.p-button:disabled{
/* variant: primary or default */
--p-button-primary-background: var(--background-disabled-grey);
--p-button-primary-hover-background: var(--background-disabled-grey);
--p-button-primary-active-background: var(--background-disabled-grey);
--p-button-primary-color: var(--text-disabled-grey);
--p-button-primary-hover-color: var(--text-disabled-grey);
--p-button-primary-active-color: var(--text-disabled-grey);
/* variant: danger */
--p-button-danger-background: var(--background-disabled-grey);
--p-button-danger-hover-background: var(--background-disabled-grey);
--p-button-danger-active-background: var(--background-disabled-grey);
--p-button-danger-color: var(--text-disabled-grey);
--p-button-danger-hover-color: var(--text-disabled-grey);
--p-button-danger-active-color: var(--text-disabled-grey);
/* variant: secondary and tertiary */
--p-button-secondary-background: var(--background-transparent);
--p-button-secondary-hover-background: var(--background-transparent);
--p-button-secondary-active-background: var(--background-transparent);
--p-button-secondary-border-color: var(--border-disabled-grey);
--p-button-secondary-hover-border-color: var(--border-disabled-grey);
--p-button-secondary-active-border-color: var(--border-disabled-grey);
--p-button-outlined-primary-hover-background: var(--background-transparent);
--p-button-outlined-primary-active-background: var(--background-transparent);
--p-button-outlined-primary-border-color: var(--border-disabled-grey);
--p-button-outlined-primary-color: var(--text-disabled-grey);
--p-button-outlined-secondary-hover-background: var(--background-transparent);
--p-button-outlined-secondary-active-background: var(--background-transparent);
--p-button-outlined-secondary-border-color: var(--border-disabled-grey);
--p-button-outlined-secondary-color: var(--text-disabled-grey);
/* variant: no-outline */
--p-button-text-primary-hover-background: var(--background-transparent);
--p-button-text-primary-active-background: var(--background-transparent);
--p-button-text-primary-color: var(--text-disabled-grey);
-webkit-user-select: none;
user-select: none;
}
</style>

View File

@ -1,6 +1,10 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import primeVue from 'primevue/config'
createApp(App).mount('#app')
const app = createApp(App)
app.use(primeVue)
app.mount('#app')

212
test/VButton.spec.ts Normal file
View File

@ -0,0 +1,212 @@
import { mount } from '@vue/test-utils'
import VButton from '@/components/button/VButton.vue'
import {test, expect, describe, vi} from 'vitest'
describe('VButton', () => {
test('Displays button label', () => {
const wrapper = mount(VButton, {
props: {
label: 'button label',
title: 'button'
}
})
// Check that the props label has gone through
expect(wrapper.props('label')).toBe('button label')
// Checks that the rendering contains the expected value
expect(wrapper.text()).toContain('button label')
});
test('Displays only icon button when iconOnly props is set', () => {
const wrapper = mount(VButton, {
props: {
icon: 'ri-settings-4-line',
iconOnly: true,
label: 'label',
title: 'button'
}
})
const button = wrapper.find('button')
// check the rendering doesn't contain any button label
expect(button.text()).toBe('')
// Check the icon is present
const icon = button.find('.p-button-icon')
expect(icon.exists()).toBe(true)
expect(icon.classes()).toContain('ri-settings-4-line')
// check that the aria-label attribute is correctly defined
expect(button.attributes('aria-label')).toBe('label')
})
test('Displays label and button icon when both are set', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
icon: 'ri-settings-4-line'
}
})
const button = wrapper.find('button')
// check the rendering contains button label value
expect(button.text()).toBe('label')
// Check the icon is present
const icon = button.find('.p-button-icon')
expect(icon.exists()).toBe(true)
expect(icon.classes()).toContain('ri-settings-4-line')
// check if the button icon is shown on left by default
expect(icon.classes()).toContain('p-button-icon-left')
// check that the aria-label attribute is correctly defined
expect(button.attributes('aria-label')).toBe('label')
})
test('Displays button icon on right when iconRight props is set', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
icon: 'ri-settings-4-line',
iconRight: true
}
})
const button = wrapper.find('button')
// Check if button icon is on right
const iconRight = button.find('.p-button-icon-right')
expect(iconRight.exists()).toBe(true)
expect(iconRight.classes()).not.toContain('p-button-icon-left')
})
test('Disabled button', async () => {
const onClick = vi.fn()
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
disabled: true,
onClick,
}
})
const button = wrapper.find('button')
await button.trigger('click')
// check disabled props is set
expect(button.attributes('disabled')).toBeDefined()
// check aria-disabled atribute is set
expect(button.attributes('aria-disabled')).toBe('true')
// check that the onClck function hasn't been called
expect(onClick).not.toHaveBeenCalled()
})
test('small button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
size: 'sm'
}
})
const button = wrapper.find('button')
expect(button.classes()).toContain('p-button-sm')
expect(button.classes()).not.toContain('p-button-lg')
expect(button.classes().some(c => c.includes('text-body-SM'))).toBe(true)
})
test('large button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
size: 'lg'
}
})
const button = wrapper.find('button')
expect(button.classes()).toContain('p-button-lg')
expect(button.classes()).not.toContain('p-button-sm')
expect(button.classes().some(c => c.includes('text-body-LG'))).toBe(true)
})
test('medium button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
}
})
const button = wrapper.find('button')
expect(button.classes()).not.toContain('p-button-lg')
expect(button.classes()).not.toContain('p-button-sm')
expect(button.classes().some(c => c.includes('text-body-MD'))).toBe(true)
})
test('primary button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
}
})
const button = wrapper.find('button')
expect(button.classes()).not.toContain('p-button-secondary')
expect(button.classes()).not.toContain('p-button-outlined')
expect(button.classes()).not.toContain('p-button-text')
expect(button.classes()).not.toContain('p-button-danger')
})
test('secondary button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
secondary: true,
}
})
const button = wrapper.find('button')
expect(button.classes()).toContain('p-button-secondary')
expect(button.classes()).toContain('p-button-outlined')
expect(button.classes()).not.toContain('p-button-text')
expect(button.classes()).not.toContain('p-button-danger')
expect(button.attributes('data-p-severity')).toBe('secondary')
})
test('tertiary button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
tertiary: true
}
})
const button = wrapper.find('button')
expect(button.classes()).not.toContain('p-button-secondary')
expect(button.classes()).toContain('p-button-outlined')
expect(button.classes()).not.toContain('p-button-text')
expect(button.classes()).not.toContain('p-button-danger')
})
test('no outlined button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
noOutline: true,
}
})
const button = wrapper.find('button')
expect(button.classes()).not.toContain('p-button-secondary')
expect(button.classes()).not.toContain('p-button-outlined')
expect(button.classes()).toContain('p-button-text')
expect(button.classes()).not.toContain('p-button-danger')
})
test('danger variant button', () => {
const wrapper = mount(VButton, {
props: {
title: 'button',
label: 'label',
danger: true,
}
})
const button = wrapper.find('button')
expect(button.classes()).not.toContain('p-button-secondary')
expect(button.classes()).not.toContain('p-button-outlined')
expect(button.classes()).not.toContain('p-button-text')
expect(button.classes()).toContain('p-button-danger')
expect(button.attributes('data-p-severity')).toBe('danger')
})
})

View File

@ -1,12 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "test/VButton.spec.ts"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@visua/*": ["./node_modules/@cellule-financiere-pmo/visua/output/*"]
}
}
}

View File

@ -7,5 +7,8 @@
{
"path": "./tsconfig.app.json"
}
]
],
"compilerOptions": {
"types": ["vitest"]
}
}

View File

@ -1,8 +1,8 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
@ -12,7 +12,13 @@ export default defineConfig({
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@visua': path.resolve(__dirname, './node_modules/@cellule-financiere-pmo/visua/output')
},
},
test: {
globals: true,
environment: 'jsdom',
include: ['test/**/*.spec.ts'],
}
})