@300codes/design-system 1.0.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 +155 -0
- package/package.json +63 -0
- package/src/components/BaseIcon/BaseIcon.stories.ts +66 -0
- package/src/components/BaseIcon/BaseIcon.vue +96 -0
- package/src/components/BaseIcon/index.ts +2 -0
- package/src/components/BaseLabel/BaseLabel.stories.ts +114 -0
- package/src/components/BaseLabel/BaseLabel.vue +149 -0
- package/src/components/BaseLabel/index.ts +2 -0
- package/src/components/BaseTooltip/BaseTooltip.stories.ts +113 -0
- package/src/components/BaseTooltip/BaseTooltip.vue +123 -0
- package/src/components/BaseTooltip/index.ts +2 -0
- package/src/components/ButtonWithIcon/ButtonWithIcon.stories.ts +149 -0
- package/src/components/ButtonWithIcon/ButtonWithIcon.vue +77 -0
- package/src/components/ButtonWithIcon/index.ts +2 -0
- package/src/components/CheckboxInput/CheckboxInput.stories.ts +99 -0
- package/src/components/CheckboxInput/CheckboxInput.vue +176 -0
- package/src/components/CheckboxInput/index.ts +2 -0
- package/src/components/LabelInput/LabelInput.vue +111 -0
- package/src/components/LabelInput/index.ts +2 -0
- package/src/components/RadioInput/RadioInput.stories.ts +114 -0
- package/src/components/RadioInput/RadioInput.vue +174 -0
- package/src/components/RadioInput/index.ts +2 -0
- package/src/components/SearchInput/SearchInput.stories.ts +103 -0
- package/src/components/SearchInput/SearchInput.vue +83 -0
- package/src/components/SearchInput/index.ts +2 -0
- package/src/components/SelectInput/SelectInput.stories.ts +111 -0
- package/src/components/SelectInput/SelectInput.vue +497 -0
- package/src/components/SelectInput/index.ts +2 -0
- package/src/components/SelectInputField/SelectInputField.stories.ts +141 -0
- package/src/components/SelectInputField/SelectInputField.vue +64 -0
- package/src/components/SelectInputField/index.ts +2 -0
- package/src/components/SimpleButton/SimpleButton.stories.ts +143 -0
- package/src/components/SimpleButton/SimpleButton.vue +193 -0
- package/src/components/SimpleButton/index.ts +2 -0
- package/src/components/TabsList/TabsList.stories.ts +83 -0
- package/src/components/TabsList/TabsList.vue +156 -0
- package/src/components/TabsList/index.ts +2 -0
- package/src/components/TextInput/TextInput.stories.ts +125 -0
- package/src/components/TextInput/TextInput.vue +273 -0
- package/src/components/TextInput/components/InputIconButton.vue +54 -0
- package/src/components/TextInput/index.ts +2 -0
- package/src/components/TextInputField/TextInputField.stories.ts +133 -0
- package/src/components/TextInputField/TextInputField.vue +93 -0
- package/src/components/TextInputField/index.ts +2 -0
- package/src/components/index.ts +15 -0
- package/src/css/tokens.css +417 -0
- package/src/types/icon.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# @300codes/design-system
|
|
2
|
+
|
|
3
|
+
A Vue 3 component library built on Tailwind CSS v4 and CSS custom properties. Ships as raw Vue SFCs — no compilation step, your bundler handles everything.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @300codes/design-system
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Peer dependencies:** `vue ^3.5.0`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
### 1. Tailwind CSS
|
|
18
|
+
|
|
19
|
+
Add the library to your Tailwind content scan in your main CSS file:
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
@import "tailwindcss";
|
|
23
|
+
@source "../node_modules/@300codes/design-system/src/**/*.{vue,ts}";
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 2. CSS Tokens
|
|
27
|
+
|
|
28
|
+
The library uses a three-layer CSS token system:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
component token → global token (Tailwind @theme) → hardcoded fallback
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Copy `tokens.css` from the library into your project and import it:
|
|
35
|
+
|
|
36
|
+
```css
|
|
37
|
+
@import "tailwindcss";
|
|
38
|
+
@source "../node_modules/@300codes/design-system/src/**/*.{vue,ts}";
|
|
39
|
+
@import "./tokens.css";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Customize tokens to match your brand:
|
|
43
|
+
|
|
44
|
+
```css
|
|
45
|
+
/* tokens.css */
|
|
46
|
+
:root {
|
|
47
|
+
--simpleButton-bg: #your-color;
|
|
48
|
+
--simpleButton-fg: #ffffff;
|
|
49
|
+
--textInput-border: #d1d5db;
|
|
50
|
+
--textInput-focus-outline: #6366f1;
|
|
51
|
+
/* ... */
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The full token reference is in `node_modules/@300codes/design-system/src/css/tokens.css`.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
Components are imported via subpaths — import only what you use:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { SimpleButton } from '@300codes/design-system/simple-button'
|
|
65
|
+
import { TextInputField } from '@300codes/design-system/text-input-field'
|
|
66
|
+
import { SelectInputField } from '@300codes/design-system/select-input-field'
|
|
67
|
+
import { BaseIcon } from '@300codes/design-system/base-icon'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```vue
|
|
71
|
+
<template>
|
|
72
|
+
<SimpleButton variant="primary" size="md">Click me</SimpleButton>
|
|
73
|
+
|
|
74
|
+
<TextInputField label="Email" help-text="We'll never share your email." />
|
|
75
|
+
|
|
76
|
+
<SelectInputField
|
|
77
|
+
label="Country"
|
|
78
|
+
:options="[{ value: 'pl', label: 'Poland' }]"
|
|
79
|
+
/>
|
|
80
|
+
</template>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Components
|
|
86
|
+
|
|
87
|
+
| Import path | Component | Description |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `/simple-button` | `SimpleButton` | Button & link. Variants: primary, secondary, tertiary. Sizes: sm, md. |
|
|
90
|
+
| `/button-with-icon` | `ButtonWithIcon` | SimpleButton with icon slot. |
|
|
91
|
+
| `/base-label` | `BaseLabel` | Badge/label. Variants: primary, secondary, tertiary. |
|
|
92
|
+
| `/base-icon` | `BaseIcon` | Dynamic SVG loader. |
|
|
93
|
+
| `/base-tooltip` | `BaseTooltip` | Tooltip with close button. Sizes: md, lg. |
|
|
94
|
+
| `/text-input` | `TextInput` | Text input with optional icons. Sizes: sm, md, lg. |
|
|
95
|
+
| `/text-input-field` | `TextInputField` | TextInput + label + help text + status icons. |
|
|
96
|
+
| `/select-input` | `SelectInput` | Dropdown (native sheet on mobile). Sizes: sm, md, lg. |
|
|
97
|
+
| `/select-input-field` | `SelectInputField` | SelectInput + label + help text. |
|
|
98
|
+
| `/checkbox-input` | `CheckboxInput` | Checkbox with label. Sizes: sm, md, lg. |
|
|
99
|
+
| `/radio-input` | `RadioInput` | Radio button with label. Sizes: sm, md, lg. |
|
|
100
|
+
| `/tabs-list` | `TabsList` | Tab navigation. Sizes: md, lg. |
|
|
101
|
+
| `/search-input` | `SearchInput` | Search-styled text input. |
|
|
102
|
+
| `/label-input` | `LabelInput` | Label + help text wrapper (for custom inputs). |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Icons
|
|
107
|
+
|
|
108
|
+
### Required icons
|
|
109
|
+
|
|
110
|
+
Several components rely on SVG icons served from `/icons/` in your public directory. You must provide these files:
|
|
111
|
+
|
|
112
|
+
| File | Used by |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `chevron-down.svg` | `SelectInput` (dropdown arrow) |
|
|
115
|
+
| `search.svg` | `SearchInput` |
|
|
116
|
+
| `close.svg` | `BaseTooltip`, `SearchInput` |
|
|
117
|
+
| `close-circle.svg` | Input clear button |
|
|
118
|
+
| `check.svg` | `CheckboxInput` |
|
|
119
|
+
| `success.svg` | `LabelInput` (success state) |
|
|
120
|
+
| `error.svg` | `LabelInput` (error state) |
|
|
121
|
+
| `not-found.svg` | `BaseIcon` (fallback when icon is missing) |
|
|
122
|
+
|
|
123
|
+
You can copy all icons from `node_modules/@300codes/design-system/public/icons/` into your project's `public/icons/` folder:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cp -r node_modules/@300codes/design-system/public/icons public/
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Using BaseIcon
|
|
130
|
+
|
|
131
|
+
`BaseIcon` dynamically loads any SVG from the `/icons/` path. You can add your own icons alongside the required ones:
|
|
132
|
+
|
|
133
|
+
```vue
|
|
134
|
+
<BaseIcon name="search" size="md" />
|
|
135
|
+
<BaseIcon name="close" size="lg" />
|
|
136
|
+
<BaseIcon name="your-custom-icon" size="sm" />
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Available sizes: `2xs` · `xs` · `sm` · `md` · `lg` · `xl` · `2xl` · `3xl` · `auto`
|
|
140
|
+
|
|
141
|
+
By default icons are loaded from `/icons/{name}.svg`. If your icons live elsewhere, pass a custom `basePath` prop:
|
|
142
|
+
|
|
143
|
+
```vue
|
|
144
|
+
<BaseIcon name="star" base-path="/assets/icons" size="md" />
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Storybook
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm run storybook
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Opens at `http://localhost:6006` with live examples of all components.
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@300codes/design-system",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src/components",
|
|
7
|
+
"src/types",
|
|
8
|
+
"src/css"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
"./base-label": "./src/components/BaseLabel/index.ts",
|
|
12
|
+
"./simple-button": "./src/components/SimpleButton/index.ts",
|
|
13
|
+
"./base-icon": "./src/components/BaseIcon/index.ts",
|
|
14
|
+
"./button-with-icon": "./src/components/ButtonWithIcon/index.ts",
|
|
15
|
+
"./label-input": "./src/components/LabelInput/index.ts",
|
|
16
|
+
"./text-input": "./src/components/TextInput/index.ts",
|
|
17
|
+
"./text-input-field": "./src/components/TextInputField/index.ts",
|
|
18
|
+
"./select-input": "./src/components/SelectInput/index.ts",
|
|
19
|
+
"./select-input-field": "./src/components/SelectInputField/index.ts",
|
|
20
|
+
"./checkbox-input": "./src/components/CheckboxInput/index.ts",
|
|
21
|
+
"./radio-input": "./src/components/RadioInput/index.ts",
|
|
22
|
+
"./tabs-list": "./src/components/TabsList/index.ts",
|
|
23
|
+
"./search-input": "./src/components/SearchInput/index.ts",
|
|
24
|
+
"./base-tooltip": "./src/components/BaseTooltip/index.ts"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "vue-tsc --noEmit",
|
|
28
|
+
"storybook": "storybook dev -p 6006",
|
|
29
|
+
"build-storybook": "storybook build",
|
|
30
|
+
"lint": "eslint .",
|
|
31
|
+
"lint:fix": "eslint . --fix",
|
|
32
|
+
"format": "prettier --write .",
|
|
33
|
+
"type-check": "vue-tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"vue": "^3.5.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^9.0.0",
|
|
40
|
+
"@storybook/addon-docs": "10.3.5",
|
|
41
|
+
"@storybook/vue3": "^10.3.5",
|
|
42
|
+
"@storybook/vue3-vite": "^10.3.5",
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"@vitejs/plugin-vue": "^5.0.0",
|
|
45
|
+
"autoprefixer": "^10.4.0",
|
|
46
|
+
"eslint": "^9.0.0",
|
|
47
|
+
"eslint-plugin-storybook": "10.3.5",
|
|
48
|
+
"eslint-plugin-vue": "^9.0.0",
|
|
49
|
+
"globals": "^15.0.0",
|
|
50
|
+
"postcss": "^8.4.0",
|
|
51
|
+
"prettier": "^3.0.0",
|
|
52
|
+
"tailwindcss": "^4.2.2",
|
|
53
|
+
"typescript": "^5.4.0",
|
|
54
|
+
"typescript-eslint": "^8.0.0",
|
|
55
|
+
"vite": "^5.0.0",
|
|
56
|
+
"vue-eslint-parser": "^9.0.0",
|
|
57
|
+
"vue-tsc": "^2.0.0"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@tailwindcss/vite": "^4.2.2",
|
|
61
|
+
"@vueuse/core": "^10.0.0"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import type { ConcreteComponent } from 'vue';
|
|
3
|
+
import type { BaseIconProps } from './BaseIcon.vue';
|
|
4
|
+
import BaseIcon from './BaseIcon.vue';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<BaseIconProps> = {
|
|
7
|
+
title: 'Components/BaseIcon',
|
|
8
|
+
component: BaseIcon as unknown as ConcreteComponent<BaseIconProps>,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
name: { control: 'text' },
|
|
12
|
+
iconPath: { control: 'text' },
|
|
13
|
+
size: {
|
|
14
|
+
control: 'select',
|
|
15
|
+
options: ['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', 'auto'],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<BaseIconProps>;
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: { name: 'search', size: 'md', iconPath: '/icons' },
|
|
25
|
+
render: (args: BaseIconProps) => ({
|
|
26
|
+
components: { BaseIcon },
|
|
27
|
+
setup() { return { args }; },
|
|
28
|
+
template: '<BaseIcon v-bind="args" />',
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Search: Story = {
|
|
33
|
+
args: { name: 'search', size: 'md', iconPath: '/icons' },
|
|
34
|
+
render: (args: BaseIconProps) => ({
|
|
35
|
+
components: { BaseIcon },
|
|
36
|
+
setup() { return { args }; },
|
|
37
|
+
template: '<BaseIcon v-bind="args" />',
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const NotFound: Story = {
|
|
42
|
+
args: { name: '', size: 'md', iconPath: '/icons' },
|
|
43
|
+
render: (args: BaseIconProps) => ({
|
|
44
|
+
components: { BaseIcon },
|
|
45
|
+
setup() { return { args }; },
|
|
46
|
+
template: '<BaseIcon v-bind="args" />',
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const AllSizes: Story = {
|
|
51
|
+
render: () => ({
|
|
52
|
+
components: { BaseIcon },
|
|
53
|
+
template: `
|
|
54
|
+
<div class="flex items-end gap-4">
|
|
55
|
+
<BaseIcon name="search" size="2xs" />
|
|
56
|
+
<BaseIcon name="search" size="xs" />
|
|
57
|
+
<BaseIcon name="search" size="sm" />
|
|
58
|
+
<BaseIcon name="search" size="md" />
|
|
59
|
+
<BaseIcon name="search" size="lg" />
|
|
60
|
+
<BaseIcon name="search" size="xl" />
|
|
61
|
+
<BaseIcon name="search" size="2xl" />
|
|
62
|
+
<BaseIcon name="search" size="3xl" />
|
|
63
|
+
</div>
|
|
64
|
+
`,
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, onServerPrefetch, ref, watch } from 'vue';
|
|
3
|
+
import type { IconSize } from '../../types/icon';
|
|
4
|
+
|
|
5
|
+
export interface BaseIconProps {
|
|
6
|
+
name: string;
|
|
7
|
+
iconPath?: string;
|
|
8
|
+
size?: IconSize;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<BaseIconProps>(), {
|
|
12
|
+
iconPath: '/icons',
|
|
13
|
+
size: 'md',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const sizeClasses: Record<IconSize, string> = {
|
|
17
|
+
'2xs': 'w-3 min-w-3 h-3',
|
|
18
|
+
'xs': 'w-4 min-w-4 h-4',
|
|
19
|
+
'sm': 'w-5 min-w-5 h-5',
|
|
20
|
+
'md': 'w-6 min-w-6 h-6',
|
|
21
|
+
'lg': 'w-8 min-w-8 h-8',
|
|
22
|
+
'xl': 'w-10 min-w-10 h-10',
|
|
23
|
+
'2xl': 'w-12 min-w-12 h-12',
|
|
24
|
+
'3xl': 'w-16 min-w-16 h-16',
|
|
25
|
+
'auto': '',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const iconClasses = computed(() => sizeClasses[props.size]);
|
|
29
|
+
|
|
30
|
+
const svgHtml = ref('');
|
|
31
|
+
const spanRef = ref<HTMLElement | null>(null);
|
|
32
|
+
const isServer = typeof window === 'undefined';
|
|
33
|
+
const notFound = '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><path d="M48,48V208H80a8,8,0,0,1,0,16H40a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8H80a8,8,0,0,1,0,16ZM216,32H176a8,8,0,0,0,0,16h32V208H176a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32Z"></path></svg>';
|
|
34
|
+
|
|
35
|
+
async function getSvgContent(name: string): Promise<string> {
|
|
36
|
+
const warnNotFound = () =>
|
|
37
|
+
console.warn(`[BaseIcon] Icon "${name}" not found at ${props.iconPath}/${name}.svg`);
|
|
38
|
+
|
|
39
|
+
if (isServer) {
|
|
40
|
+
const path = await import('path');
|
|
41
|
+
const fs = await import('fs/promises');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const filePath = path.join(process.cwd(), 'public', props.iconPath, `${name}.svg`);
|
|
45
|
+
return await fs.readFile(filePath, 'utf8');
|
|
46
|
+
} catch {
|
|
47
|
+
warnNotFound();
|
|
48
|
+
return notFound;
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${props.iconPath}/${name}.svg`);
|
|
53
|
+
if (res.ok) return await res.text();
|
|
54
|
+
throw new Error();
|
|
55
|
+
} catch {
|
|
56
|
+
warnNotFound();
|
|
57
|
+
return notFound;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isServer) {
|
|
63
|
+
onServerPrefetch(async () => {
|
|
64
|
+
svgHtml.value = await getSvgContent(props.name);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
watch(
|
|
69
|
+
() => props.name,
|
|
70
|
+
async (newName) => {
|
|
71
|
+
svgHtml.value = await getSvgContent(newName);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
onMounted(async () => {
|
|
76
|
+
if (!spanRef.value?.innerHTML.length) {
|
|
77
|
+
svgHtml.value = await getSvgContent(props.name);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<span
|
|
84
|
+
ref="spanRef"
|
|
85
|
+
:class="['baseIcon', 'inline-flex items-center justify-center', iconClasses]"
|
|
86
|
+
:aria-hidden="true"
|
|
87
|
+
v-html="svgHtml"
|
|
88
|
+
/>
|
|
89
|
+
</template>
|
|
90
|
+
|
|
91
|
+
<style scoped>
|
|
92
|
+
:deep(svg) {
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: 100%;
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import type { ConcreteComponent } from 'vue';
|
|
3
|
+
import type { BaseLabelProps } from './BaseLabel.vue';
|
|
4
|
+
import BaseLabel from './BaseLabel.vue';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<BaseLabelProps> = {
|
|
7
|
+
title: 'Components/BaseLabel',
|
|
8
|
+
component: BaseLabel as unknown as ConcreteComponent<BaseLabelProps>,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
variant: {
|
|
12
|
+
control: 'select',
|
|
13
|
+
options: ['primary', 'secondary', 'tertiary'],
|
|
14
|
+
},
|
|
15
|
+
size: {
|
|
16
|
+
control: 'select',
|
|
17
|
+
options: ['sm', 'md'],
|
|
18
|
+
},
|
|
19
|
+
as: {
|
|
20
|
+
control: 'select',
|
|
21
|
+
options: ['span', 'button'],
|
|
22
|
+
},
|
|
23
|
+
disabled: { control: 'boolean' },
|
|
24
|
+
href: { control: 'text' },
|
|
25
|
+
target: { control: 'text' },
|
|
26
|
+
rel: { control: 'text' },
|
|
27
|
+
type: {
|
|
28
|
+
control: 'select',
|
|
29
|
+
options: ['button', 'submit', 'reset'],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default meta;
|
|
35
|
+
type Story = StoryObj<BaseLabelProps>;
|
|
36
|
+
|
|
37
|
+
export const Primary: Story = {
|
|
38
|
+
args: { variant: 'primary', size: 'md' },
|
|
39
|
+
render: (args: BaseLabelProps) => ({
|
|
40
|
+
components: { BaseLabel },
|
|
41
|
+
setup() { return { args }; },
|
|
42
|
+
template: '<BaseLabel v-bind="args">Label</BaseLabel>',
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Secondary: Story = {
|
|
47
|
+
args: { variant: 'secondary', size: 'md' },
|
|
48
|
+
render: (args: BaseLabelProps) => ({
|
|
49
|
+
components: { BaseLabel },
|
|
50
|
+
setup() { return { args }; },
|
|
51
|
+
template: '<BaseLabel v-bind="args">Label</BaseLabel>',
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const Tertiary: Story = {
|
|
56
|
+
args: { variant: 'tertiary', size: 'md' },
|
|
57
|
+
render: (args: BaseLabelProps) => ({
|
|
58
|
+
components: { BaseLabel },
|
|
59
|
+
setup() { return { args }; },
|
|
60
|
+
template: '<BaseLabel v-bind="args">Label</BaseLabel>',
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const AsButton: Story = {
|
|
65
|
+
args: { variant: 'primary', size: 'md', as: 'button' },
|
|
66
|
+
render: (args: BaseLabelProps) => ({
|
|
67
|
+
components: { BaseLabel },
|
|
68
|
+
setup() { return { args }; },
|
|
69
|
+
template: '<BaseLabel v-bind="args">Clickable</BaseLabel>',
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const AsLink: Story = {
|
|
74
|
+
args: { variant: 'primary', size: 'md', href: 'https://example.com', target: '_blank', rel: 'noopener noreferrer' },
|
|
75
|
+
render: (args: BaseLabelProps) => ({
|
|
76
|
+
components: { BaseLabel },
|
|
77
|
+
setup() { return { args }; },
|
|
78
|
+
template: '<BaseLabel v-bind="args">Link</BaseLabel>',
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Disabled: Story = {
|
|
83
|
+
args: { variant: 'primary', size: 'md', as: 'button', disabled: true },
|
|
84
|
+
render: (args: BaseLabelProps) => ({
|
|
85
|
+
components: { BaseLabel },
|
|
86
|
+
setup() { return { args }; },
|
|
87
|
+
template: '<BaseLabel v-bind="args">Disabled</BaseLabel>',
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const Sizes: Story = {
|
|
92
|
+
render: () => ({
|
|
93
|
+
components: { BaseLabel },
|
|
94
|
+
template: `
|
|
95
|
+
<div class="flex items-center gap-4">
|
|
96
|
+
<BaseLabel variant="primary" size="md">md label</BaseLabel>
|
|
97
|
+
<BaseLabel variant="primary" size="sm">sm label</BaseLabel>
|
|
98
|
+
</div>
|
|
99
|
+
`,
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const Variants: Story = {
|
|
104
|
+
render: () => ({
|
|
105
|
+
components: { BaseLabel },
|
|
106
|
+
template: `
|
|
107
|
+
<div class="flex items-center gap-4">
|
|
108
|
+
<BaseLabel variant="primary">Primary</BaseLabel>
|
|
109
|
+
<BaseLabel variant="secondary">Secondary</BaseLabel>
|
|
110
|
+
<BaseLabel variant="tertiary">Tertiary</BaseLabel>
|
|
111
|
+
</div>
|
|
112
|
+
`,
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'vue';
|
|
4
|
+
|
|
5
|
+
export interface BaseLabelProps {
|
|
6
|
+
variant?: 'primary' | 'secondary' | 'tertiary';
|
|
7
|
+
size?: 'sm' | 'md';
|
|
8
|
+
as?: 'span' | 'button';
|
|
9
|
+
href?: string;
|
|
10
|
+
target?: AnchorHTMLAttributes['target'];
|
|
11
|
+
rel?: AnchorHTMLAttributes['rel'];
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
type?: ButtonHTMLAttributes['type'];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<BaseLabelProps>(), {
|
|
17
|
+
variant: 'primary',
|
|
18
|
+
size: 'md',
|
|
19
|
+
as: 'span',
|
|
20
|
+
disabled: false,
|
|
21
|
+
href: undefined,
|
|
22
|
+
target: undefined,
|
|
23
|
+
rel: undefined,
|
|
24
|
+
type: undefined,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const tag = computed(() => (props.href ? 'a' : props.as));
|
|
28
|
+
|
|
29
|
+
const isInteractive = computed(() => tag.value !== 'span');
|
|
30
|
+
|
|
31
|
+
const nativeAttrs = computed((): Record<string, unknown> => {
|
|
32
|
+
if (props.href) {
|
|
33
|
+
return {
|
|
34
|
+
href: props.disabled ? undefined : props.href,
|
|
35
|
+
target: props.target,
|
|
36
|
+
rel: props.rel,
|
|
37
|
+
'aria-disabled': props.disabled ? 'true' : undefined,
|
|
38
|
+
tabindex: props.disabled ? -1 : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (props.as === 'button') {
|
|
43
|
+
return {
|
|
44
|
+
type: props.type ?? 'button',
|
|
45
|
+
disabled: props.disabled,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {};
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<component
|
|
55
|
+
:is="tag"
|
|
56
|
+
v-bind="nativeAttrs"
|
|
57
|
+
:class="[
|
|
58
|
+
'baseLabel',
|
|
59
|
+
'no-underline inline-flex items-center justify-center',
|
|
60
|
+
`baseLabel--${props.variant}`,
|
|
61
|
+
`baseLabel--${props.size}`,
|
|
62
|
+
{
|
|
63
|
+
'baseLabel--interactive': isInteractive,
|
|
64
|
+
'baseLabel--disabled': props.disabled,
|
|
65
|
+
},
|
|
66
|
+
]"
|
|
67
|
+
>
|
|
68
|
+
<slot />
|
|
69
|
+
</component>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<style scoped>
|
|
73
|
+
@reference "tailwindcss";
|
|
74
|
+
|
|
75
|
+
.baseLabel {
|
|
76
|
+
--_fg: var(--baseLabel-fg, #ffffff);
|
|
77
|
+
background-color: var(--baseLabel-bg, #0024d6);
|
|
78
|
+
color: var(--_fg);
|
|
79
|
+
border: var(--baseLabel-border-width, 1px) solid var(--baseLabel-border, transparent);
|
|
80
|
+
border-radius: var(--baseLabel-radius, 3.5rem);
|
|
81
|
+
padding: var(--baseLabel-py, 0.125rem) var(--baseLabel-px, 0.5rem);
|
|
82
|
+
min-height: var(--baseLabel-min-h, 1.5rem);
|
|
83
|
+
font-size: var(--baseLabel-font-size, 0.75rem);
|
|
84
|
+
font-weight: var(--baseLabel-font-weight, 600);
|
|
85
|
+
line-height: 1.2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.baseLabel--interactive {
|
|
89
|
+
@apply outline-offset-2 cursor-pointer select-none;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.baseLabel--interactive:hover:not(.baseLabel--disabled) {
|
|
93
|
+
background-color: var(--baseLabel-bg-hover, #0e161b);
|
|
94
|
+
border-color: var(--baseLabel-border-hover, #0e161b);
|
|
95
|
+
color: var(--baseLabel-fg-hover, #ffffff);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.baseLabel--interactive:focus-visible {
|
|
99
|
+
outline: 2px solid var(--baseLabel-ring, #0066cc);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.baseLabel--disabled {
|
|
103
|
+
@apply cursor-not-allowed pointer-events-none opacity-50;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ── sm ── */
|
|
107
|
+
|
|
108
|
+
.baseLabel--sm {
|
|
109
|
+
min-height: var(--baseLabel-sm-min-h, 1.25rem);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ── secondary ── */
|
|
113
|
+
|
|
114
|
+
.baseLabel--secondary {
|
|
115
|
+
--_fg: var(--baseLabel-secondary-fg, #0024d6);
|
|
116
|
+
background-color: var(--baseLabel-secondary-bg, #ffffff);
|
|
117
|
+
color: var(--_fg);
|
|
118
|
+
border-color: var(--baseLabel-secondary-border, #0024d6);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.baseLabel--secondary.baseLabel--interactive:hover:not(.baseLabel--disabled) {
|
|
122
|
+
background-color: var(--baseLabel-secondary-bg-hover, #f3f4f6);
|
|
123
|
+
border-color: var(--baseLabel-secondary-border-hover, #0e161b);
|
|
124
|
+
color: var(--baseLabel-secondary-fg-hover, #0e161b);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.baseLabel--secondary.baseLabel--interactive:focus-visible {
|
|
128
|
+
outline: 2px solid var(--baseLabel-secondary-ring, #0066cc);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ── tertiary ── */
|
|
132
|
+
|
|
133
|
+
.baseLabel--tertiary {
|
|
134
|
+
--_fg: var(--baseLabel-tertiary-fg, #0e161b);
|
|
135
|
+
background-color: var(--baseLabel-tertiary-bg, transparent);
|
|
136
|
+
color: var(--_fg);
|
|
137
|
+
border-color: var(--baseLabel-tertiary-border, transparent);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.baseLabel--tertiary.baseLabel--interactive:hover:not(.baseLabel--disabled) {
|
|
141
|
+
background-color: var(--baseLabel-tertiary-bg-hover, #f3f4f6);
|
|
142
|
+
border-color: var(--baseLabel-tertiary-border-hover, #f3f4f6);
|
|
143
|
+
color: var(--baseLabel-tertiary-fg-hover, #0024d6);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.baseLabel--tertiary.baseLabel--interactive:focus-visible {
|
|
147
|
+
outline: 2px solid var(--baseLabel-tertiary-ring, #0066cc);
|
|
148
|
+
}
|
|
149
|
+
</style>
|