Browse Source
- Introduced ConfigProvider component to manage locale settings. - Implemented useLocale hook for localization support. - Added language JSON files for English and Chinese. - Created utility hooks for context management and namespace handling. - Developed various UI components with corresponding styles. - Integrated Vite plugin for automatic style injection based on component usage. - Established a Nuxt module for easy integration of Bolt UI components and styles.as
35 changed files with 1277 additions and 4 deletions
@ -0,0 +1,5 @@ |
|||
import { withInstall } from 'bolt-ui/utils/vue/install' |
|||
import Button from './src/Button.vue' |
|||
|
|||
export const BoButton = withInstall(Button) |
|||
export default BoButton |
|||
@ -0,0 +1,23 @@ |
|||
<template> |
|||
<button :class="[b(), m(type)]"> |
|||
<slot /> |
|||
</button> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { useNamespace } from 'bolt-ui/utils/hooks/use-namespace' |
|||
|
|||
withDefaults( |
|||
defineProps<{ |
|||
type?: 'primary' | 'secondary' | 'tertiary' | 'danger' | 'warning' | 'info' | 'success' |
|||
}>(), |
|||
{ |
|||
type: 'primary' |
|||
} |
|||
) |
|||
|
|||
const { b, m } = useNamespace('button') |
|||
defineOptions({ |
|||
name: 'BoButton' |
|||
}) |
|||
</script> |
|||
@ -0,0 +1 @@ |
|||
import 'bolt-ui/theme-chalk/src/button.scss' |
|||
@ -0,0 +1,5 @@ |
|||
import { withInstall } from 'bolt-ui/utils/vue/install' |
|||
import ConfigProvider from './src/ConfigProvider.vue' |
|||
|
|||
export const BoConfigProvider = withInstall(ConfigProvider) |
|||
export default BoConfigProvider |
|||
@ -0,0 +1,33 @@ |
|||
<template> |
|||
<slot /> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { provide, computed, watch } from 'vue' |
|||
import { ConfigToken } from './Token' |
|||
import type { LanguagesType } from 'bolt-ui/locales' |
|||
import { useLocale } from 'bolt-ui/locales' |
|||
|
|||
const props = defineProps<{ |
|||
locale: string |
|||
}>() |
|||
|
|||
const { setLocale } = useLocale() |
|||
|
|||
watch( |
|||
() => props.locale, |
|||
(newVal) => { |
|||
setLocale(newVal as LanguagesType) |
|||
}, |
|||
{ |
|||
immediate: true |
|||
} |
|||
) |
|||
|
|||
const state = computed(() => { |
|||
return { |
|||
locale: props.locale ?? 'zh' |
|||
} |
|||
}) |
|||
provide(ConfigToken, state) |
|||
</script> |
|||
@ -0,0 +1,3 @@ |
|||
import { ComputedRef, InjectionKey } from 'vue' |
|||
|
|||
export const ConfigToken: InjectionKey<ComputedRef<{ locale: string }>> = Symbol('ConfigToken') |
|||
@ -0,0 +1,10 @@ |
|||
import { inject } from 'vue' |
|||
import { ConfigToken } from './Token' |
|||
|
|||
export function useConfigProvider() { |
|||
const config = inject(ConfigToken) |
|||
if (!config) { |
|||
throw new Error('ConfigProvider not found') |
|||
} |
|||
return config |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export {} |
|||
@ -0,0 +1,2 @@ |
|||
export * from './Button' |
|||
export * from './ConfigProvider' |
|||
@ -0,0 +1,67 @@ |
|||
import { getCurrentInstance, inject, provide } from 'vue' |
|||
|
|||
import type { InjectionKey } from 'vue' |
|||
|
|||
export interface CreateContextOptions<T> { |
|||
/** 严格模式:未找到 Provider 且没有 defaultValue 时是否抛错,默认 true */ |
|||
strict?: boolean |
|||
/** 默认值工厂:当没有 Provider 时调用,用返回值兜底,避免共享状态 */ |
|||
defaultValue?: () => T |
|||
/** 自定义 InjectionKey,一般不需要传 */ |
|||
key?: InjectionKey<T> |
|||
} |
|||
|
|||
/** |
|||
* 创建一套基于 provide/inject 的上下文工具。 |
|||
* |
|||
* 使用方式: |
|||
* const FooContext = createContext<FooContext>('Foo', { |
|||
* strict: true, |
|||
* defaultValue: () => ({ ... }) |
|||
* }) |
|||
* |
|||
* FooContext.provideContext(...) |
|||
* const ctx = FooContext.useContext() |
|||
*/ |
|||
export const createContext = <T>(name?: string, options: CreateContextOptions<T> = {}) => { |
|||
const { strict = true, defaultValue, key } = options |
|||
const contextKey: InjectionKey<T> = key ?? (Symbol(name ?? 'Context') as InjectionKey<T>) |
|||
|
|||
const useContext = () => { |
|||
// 在 setup 之外调用没有意义,这里主动给出更友好的错误
|
|||
if (!getCurrentInstance()) { |
|||
throw new Error('useContext 只能在 setup 或生命周期钩子中使用') |
|||
} |
|||
|
|||
const ctx = inject<T | undefined>(contextKey, undefined) |
|||
if (ctx !== undefined) { |
|||
return ctx |
|||
} |
|||
|
|||
if (defaultValue) { |
|||
// 每次调用 defaultValue 都返回一个“新”默认值,避免跨实例共享
|
|||
return defaultValue() |
|||
} |
|||
|
|||
if (strict) { |
|||
throw new Error( |
|||
`未找到上层 Provider,请确保已经调用 provideContext 或在正确的组件树中使用(${name ?? 'Context'})` |
|||
) |
|||
} |
|||
|
|||
return undefined as unknown as T |
|||
} |
|||
|
|||
const provideContext = (context: T) => { |
|||
provide(contextKey, context) |
|||
} |
|||
|
|||
return { |
|||
/** 当前上下文使用的 InjectionKey */ |
|||
key: contextKey, |
|||
/** 在上层组件中调用,用于提供上下文 */ |
|||
provideContext, |
|||
/** 在子组件中调用,用于消费上下文,如果未找到会抛出错误 / 或返回默认值 */ |
|||
useContext |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './useClickOutside' |
|||
@ -0,0 +1,38 @@ |
|||
import { onMounted, onUnmounted, Ref } from 'vue' |
|||
|
|||
export function useClickOutside( |
|||
elementRef: Ref<HTMLElement | null> | HTMLElement, |
|||
callback: (event: MouseEvent) => void, |
|||
options: { |
|||
ignore?: Ref<HTMLElement | null>[] // 需要忽略的元素
|
|||
} = {} |
|||
) { |
|||
const { ignore = [] } = options |
|||
|
|||
const handler = (event: MouseEvent) => { |
|||
const el = elementRef instanceof HTMLElement ? elementRef : elementRef.value |
|||
|
|||
if (!el) return |
|||
|
|||
// 检查点击是否在忽略的元素内
|
|||
const isIgnored = ignore.some((ref) => { |
|||
const element = ref?.value |
|||
return element && (element === event.target || element.contains(event.target as Node)) |
|||
}) |
|||
|
|||
if (isIgnored) return |
|||
|
|||
// 检查点击是否在目标元素外
|
|||
if (!(el === event.target || el.contains(event.target as Node))) { |
|||
callback(event) |
|||
} |
|||
} |
|||
|
|||
onMounted(() => { |
|||
document.addEventListener('click', handler, true) |
|||
}) |
|||
|
|||
onUnmounted(() => { |
|||
document.removeEventListener('click', handler, true) |
|||
}) |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export * from './components' |
|||
export * from './hooks' |
|||
@ -0,0 +1,68 @@ |
|||
import { get } from 'lodash-es' |
|||
|
|||
import zh from './languages/zh.json' |
|||
import en from './languages/en.json' |
|||
import { reactive } from 'vue' |
|||
|
|||
const Languages = { |
|||
zh: zh, |
|||
en: en |
|||
} |
|||
|
|||
export type LanguagesType = keyof typeof Languages |
|||
|
|||
const LocaleState = reactive<{ |
|||
locale: LanguagesType |
|||
}>({ |
|||
locale: 'zh' |
|||
}) |
|||
|
|||
type FlattenObject<T, Prefix extends string = ''> = T extends object |
|||
? { |
|||
[K in keyof T & (string | number)]: FlattenObject< |
|||
T[K], |
|||
Prefix extends '' ? `${K}` : `${Prefix}.${K}` |
|||
> |
|||
}[keyof T & (string | number)] |
|||
: Prefix |
|||
|
|||
type FlattenKeys<T> = FlattenObject<T> |
|||
|
|||
type TranslationKey = FlattenKeys<typeof zh> |
|||
|
|||
function useLocale() { |
|||
function setLocale(locale: LanguagesType) { |
|||
LocaleState.locale = locale |
|||
} |
|||
|
|||
function getLocale(): string { |
|||
return LocaleState.locale |
|||
} |
|||
|
|||
function t(key: TranslationKey, replacements?: Record<string, string>): string { |
|||
let text: string = |
|||
LocaleState.locale in Languages |
|||
? get(Languages[LocaleState.locale], key) |
|||
: get(Languages['zh'], key) |
|||
if (!text) { |
|||
text = get(Languages['zh'], key) |
|||
if (!text) { |
|||
return key |
|||
} |
|||
} |
|||
if (replacements) { |
|||
Object.entries(replacements).forEach(([key, value]) => { |
|||
text = text.replace(new RegExp(`{${key}}`, 'g'), value) |
|||
}) |
|||
} |
|||
return text |
|||
} |
|||
|
|||
return { |
|||
setLocale, |
|||
getLocale, |
|||
t |
|||
} |
|||
} |
|||
|
|||
export { useLocale } |
|||
@ -0,0 +1,11 @@ |
|||
{ |
|||
"input": { |
|||
"placeholder": "Please enter content" |
|||
}, |
|||
"select": { |
|||
"placeholder": "Please select" |
|||
}, |
|||
"empty": { |
|||
"description": "No Data" |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
{ |
|||
"input": { |
|||
"placeholder": "请输入内容" |
|||
}, |
|||
"select": { |
|||
"placeholder": "请选择" |
|||
}, |
|||
"empty": { |
|||
"description": "暂无数据" |
|||
} |
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
import { |
|||
defineNuxtModule, |
|||
addComponent, |
|||
addImports, |
|||
createResolver, |
|||
} from '@nuxt/kit' |
|||
import { readdirSync, existsSync, readFileSync } from 'node:fs' |
|||
import { join } from 'node:path' |
|||
|
|||
export interface ModuleOptions { |
|||
/** |
|||
* Whether to import global base styles (CSS variables / design tokens). |
|||
* @default true |
|||
*/ |
|||
importStyles?: boolean |
|||
} |
|||
|
|||
function kebabCase(str: string): string { |
|||
return str.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '') |
|||
} |
|||
|
|||
// ── 从源码中提取 export function / export const 名称 ─────────
|
|||
function extractExports(filePath: string): string[] { |
|||
try { |
|||
const content = readFileSync(filePath, 'utf-8') |
|||
const names: string[] = [] |
|||
const re = /export\s+(?:function|const|let|var)\s+(\w+)/g |
|||
let m: RegExpExecArray | null |
|||
while ((m = re.exec(content)) !== null) names.push(m[1] as any) |
|||
return names |
|||
} catch { |
|||
return [] |
|||
} |
|||
} |
|||
|
|||
function isComposable(name: string): boolean { |
|||
return name.startsWith('use') || name === 'createContext' |
|||
} |
|||
|
|||
// ── Vite 插件:自动注入组件样式 ────────────────────────────
|
|||
function BoltUiStylePlugin(styleMap: Record<string, string>) { |
|||
return { |
|||
name: 'bolt-ui:style-inject', |
|||
enforce: 'post', |
|||
transform(code: string, id: string) { |
|||
if (!/\.(vue|tsx?|jsx?)$/.test(id)) return null |
|||
if (id.includes('node_modules')) return null |
|||
|
|||
const matchedStyles = Object.entries(styleMap) |
|||
.filter(([name]) => code.includes(name)) |
|||
.map(([, style]) => style) |
|||
|
|||
if (matchedStyles.length === 0) return null |
|||
|
|||
const imports = [...new Set(matchedStyles)] |
|||
.map(p => `import '${p}';`) |
|||
.join('\n') |
|||
|
|||
return { |
|||
code: imports + '\n' + code, |
|||
map: null, |
|||
} |
|||
}, |
|||
} |
|||
} |
|||
|
|||
export default defineNuxtModule<ModuleOptions>({ |
|||
meta: { |
|||
name: 'bolt-ui', |
|||
configKey: 'boltUi', |
|||
}, |
|||
defaults: { |
|||
importStyles: true, |
|||
}, |
|||
setup(options, nuxt) { |
|||
const resolver = createResolver(import.meta.url) |
|||
|
|||
// ── CSS 变量层(design tokens)────────────────────────────
|
|||
if (options.importStyles) { |
|||
nuxt.options.css.push(resolver.resolve('./theme-chalk/src/theme/index.scss')) |
|||
} |
|||
|
|||
// ── 自动扫描组件目录 ────────────────────────────────────
|
|||
const componentsDir = resolver.resolve('./components') |
|||
const componentStyleMap: Record<string, string> = {} |
|||
|
|||
for (const entry of readdirSync(componentsDir, { withFileTypes: true })) { |
|||
if (!entry.isDirectory()) continue |
|||
|
|||
const name = entry.name |
|||
const indexPath = join(componentsDir, name, 'index.ts') |
|||
if (!existsSync(indexPath)) continue |
|||
|
|||
// 注册组件
|
|||
addComponent({ |
|||
name: `Bo${name}`, |
|||
export: `Bo${name}`, |
|||
filePath: resolver.resolve(`./components/${name}/index.ts`), |
|||
}) |
|||
|
|||
// 构建样式映射(约定:theme-chalk/src/${kebab}.scss)
|
|||
const scssPath = join(componentsDir, '..', 'theme-chalk/src', `${kebabCase(name)}.scss`) |
|||
if (existsSync(scssPath)) { |
|||
componentStyleMap[`Bo${name}`] = `bolt-ui/theme-chalk/src/${kebabCase(name)}.scss` |
|||
} |
|||
} |
|||
|
|||
// ── Vite 插件:按需注入组件样式 ─────────────────────────
|
|||
nuxt.hook('vite:extendConfig', (config) => { |
|||
// @ts-ignore
|
|||
config.plugins = config.plugins || [] |
|||
config.plugins.push(BoltUiStylePlugin(componentStyleMap) as any) |
|||
}) |
|||
|
|||
// ── 自动扫描 composable 目录 ──────────────────────────
|
|||
for (const scanDir of ['hooks', 'utils', 'locales']) { |
|||
const absDir = resolver.resolve(`./${scanDir}`) |
|||
if (!existsSync(absDir)) continue |
|||
|
|||
const walk = (dir: string) => { |
|||
for (const entry of readdirSync(dir, { withFileTypes: true })) { |
|||
const fullPath = join(dir, entry.name) |
|||
if (entry.isDirectory()) { |
|||
walk(fullPath) |
|||
} else if (entry.name === 'index.ts') { |
|||
const names = extractExports(fullPath).filter(isComposable) |
|||
for (const name of names) { |
|||
addImports({ name, as: name, from: fullPath }) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
walk(absDir) |
|||
} |
|||
}, |
|||
}) |
|||
@ -0,0 +1,8 @@ |
|||
{ |
|||
"name": "bolt-ui", |
|||
"type": "module", |
|||
"private": true, |
|||
"scripts": { |
|||
"build": "tsc -p ./tsconfig.json" |
|||
} |
|||
} |
|||
@ -0,0 +1,67 @@ |
|||
const noStylesComponents: any[] = [] |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
export default (options: any = {}): any => { |
|||
let optionsResolved: any |
|||
|
|||
async function resolveOptions() { |
|||
if (optionsResolved) return optionsResolved |
|||
optionsResolved = { |
|||
exclude: undefined, |
|||
noStylesComponents: options.noStylesComponents || [], |
|||
...options |
|||
} |
|||
return optionsResolved |
|||
} |
|||
return { |
|||
type: 'component', |
|||
resolve: async (name: string) => { |
|||
const options = await resolveOptions() |
|||
|
|||
if ([...options.noStylesComponents, ...noStylesComponents].includes(name)) |
|||
return resolveComponent(name, { ...options, importStyle: false }) |
|||
else return resolveComponent(name, options) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// function kebabCase(key: string) {
|
|||
// const result = key.replace(/([A-Z])/g, ' $1').trim()
|
|||
// return result.split(' ').join('-').toLowerCase()
|
|||
// }
|
|||
|
|||
function pascalCase(key: string): string { |
|||
// 第一步:将所有分隔符(-、_、空格)替换为空格,统一处理
|
|||
const replaced = key.replace(/[-_\s]/g, ' ') |
|||
// 第二步:处理可能的连续大写字母(如HTML),在大写字母前加空格(除了开头)
|
|||
const spaced = replaced.replace(/([A-Z])/g, (_, p1, index) => { |
|||
return index === 0 ? p1 : ` ${p1}` |
|||
}) |
|||
// 第三步:去除首尾空格,并按空格拆分为单词数组
|
|||
const words = spaced.trim().split(/\s+/) |
|||
// 第四步:每个单词首字母大写,其余小写,然后拼接
|
|||
return words |
|||
.map((word) => { |
|||
if (word.length === 0) return '' |
|||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() |
|||
}) |
|||
.join('') |
|||
} |
|||
|
|||
function resolveComponent(name: string, options: any) { |
|||
if (options.exclude && name.match(options.exclude)) return |
|||
|
|||
if (!name.match(/^Bo[A-Z]/)) return |
|||
|
|||
const partialName = pascalCase(name.slice(2)) |
|||
return { |
|||
name, |
|||
from: `${'bolt-ui'}`, |
|||
sideEffects: getSideEffects(partialName) |
|||
} |
|||
} |
|||
|
|||
function getSideEffects(dirName: string) { |
|||
const componentsFolder = 'bolt-ui/components' |
|||
return [`${componentsFolder}/${dirName}/style/index`, 'bolt-ui/theme-chalk/src/theme/index.scss'] |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
@use 'core/_base' as *; |
|||
@use 'sass:selector'; |
|||
|
|||
@include setNamespace('button'); |
|||
|
|||
#{b()} { |
|||
display: inline-block; |
|||
white-space: nowrap; |
|||
cursor: pointer; |
|||
background: #fff; |
|||
// border: 1px solid #dcdfe6; |
|||
// border-color: #dcdfe6; |
|||
border-radius: 8px; |
|||
border: none; |
|||
padding: 0 20px; |
|||
height: 40px; |
|||
line-height: 40px; |
|||
font-size: 14px; |
|||
|
|||
&#{m('primary')} { |
|||
background: var(--color-primary); |
|||
color: var(--color-primary-content); |
|||
} |
|||
&#{m('secondary')} { |
|||
background: var(--color-secondary); |
|||
color: var(--color-secondary-content); |
|||
} |
|||
&#{m('tertiary')} { |
|||
background: #f8f9fa; |
|||
color: #212529; |
|||
} |
|||
&#{m('danger')} { |
|||
background: #dc3545; |
|||
color: #fff; |
|||
} |
|||
&#{m('warning')} { |
|||
background: #ffc107; |
|||
color: #212529; |
|||
} |
|||
|
|||
&:hover { |
|||
opacity: 0.9; |
|||
} |
|||
&:active { |
|||
opacity: 0.8; |
|||
} |
|||
&:disabled { |
|||
opacity: 0.5; |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
@use 'core/_base' as *; |
|||
@use 'sass:selector'; |
|||
|
|||
@include setNamespace('card'); |
|||
|
|||
#{b()} { |
|||
background-color: #fff; |
|||
border-radius: 4px; |
|||
padding: 10px; |
|||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
$prefix: 'bo'; |
|||
$namespace: ''; |
|||
|
|||
@mixin setNamespace($nc) { |
|||
$namespace: $nc !global; |
|||
} |
|||
|
|||
@function b() { |
|||
@return '.#{$prefix}-#{$namespace}'; |
|||
} |
|||
|
|||
@function e($element) { |
|||
$block: b(); |
|||
@return '#{$block}__#{$element}'; |
|||
} |
|||
|
|||
@function m($modifier) { |
|||
$block: b(); |
|||
@return '#{$block}--#{$modifier}'; |
|||
} |
|||
|
|||
@function bm($blockSuffix, $modifier) { |
|||
$block: b(); |
|||
@return '#{$block}-#{$blockSuffix}--#{$modifier}'; |
|||
} |
|||
|
|||
@function em($element, $modifier) { |
|||
$block: b(); |
|||
@return '#{$block}__#{$element}--#{$modifier}'; |
|||
} |
|||
|
|||
@function be($blockSuffix, $element) { |
|||
$block: b(); |
|||
@return '#{$block}-#{$blockSuffix}__#{$element}'; |
|||
} |
|||
|
|||
@function bem($blockSuffix, $element, $modifier) { |
|||
$block: b(); |
|||
@return '#{$block}-#{$blockSuffix}__#{$element}--#{$modifier}'; |
|||
} |
|||
|
|||
@function is($state) { |
|||
@return '.is-#{$state}'; |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
@use 'core/_base' as *; |
|||
@use 'sass:selector'; |
|||
|
|||
@include setNamespace('dropdown'); |
|||
|
|||
#{b()} { |
|||
position: relative; |
|||
|
|||
#{e('content')} { |
|||
position: absolute; |
|||
top: 100%; |
|||
left: 0; |
|||
z-index: 9999; |
|||
display: grid; |
|||
grid-template-rows: 1fr; |
|||
transition: grid-template-rows 0.1s ease-out; |
|||
overflow: hidden; |
|||
min-width: 80px; |
|||
&#{is('hidden')} { |
|||
grid-template-rows: 0fr; |
|||
} |
|||
#{e('wrapper')} { |
|||
min-height: 0; |
|||
#{e('list')} { |
|||
box-sizing: border-box; |
|||
background-color: #fff; |
|||
border: 1px solid #dcdfe6; |
|||
border-radius: 4px; |
|||
|
|||
#{e('item')} { |
|||
padding: 10px; |
|||
font-size: 14px; |
|||
line-height: 1; |
|||
display: flex; |
|||
justify-content: center; |
|||
cursor: pointer; |
|||
&:hover { |
|||
background-color: #f5f5f5; |
|||
} |
|||
} |
|||
|
|||
#{e('line')} { |
|||
width: 100%; |
|||
height: 1px; |
|||
background-color: #dcdfe6; |
|||
transform: scaleY(0.5); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
@use 'core/_base' as *; |
|||
@use 'sass:selector'; |
|||
|
|||
@include setNamespace('empty'); |
|||
|
|||
#{b()} { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 40px 20px; |
|||
text-align: center; |
|||
color: #909399; |
|||
font-size: 14px; |
|||
|
|||
#{e('image')} { |
|||
margin-bottom: 20px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
img { |
|||
display: block; |
|||
user-select: none; |
|||
} |
|||
} |
|||
|
|||
#{e('description')} { |
|||
margin-bottom: 20px; |
|||
color: #909399; |
|||
font-size: 14px; |
|||
line-height: 1.5; |
|||
} |
|||
|
|||
#{e('footer')} { |
|||
margin-top: 10px; |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
@use 'core/_base' as *; |
|||
|
|||
@include setNamespace('float'); |
|||
|
|||
#{b()} { |
|||
box-sizing: border-box; |
|||
margin: 0; |
|||
min-width: 0; |
|||
/* fixed + 块级 + 100% 会占满视口宽,getBoundingClientRect 近 innerWidth,clamp 会把 x 锁死 */ |
|||
width: max-content; |
|||
max-width: calc(100vw - 1rem); |
|||
border-radius: var(--radius-box, 0.5rem); |
|||
box-shadow: |
|||
0 4px 6px -1px rgb(0 0 0 / 0.08), |
|||
0 2px 4px -2px rgb(0 0 0 / 0.06); |
|||
background-color: var(--color-base-100, #fafafa); |
|||
border: var(--border, 1px) solid var(--color-base-300, oklch(92% 0.04 240)); |
|||
overflow: hidden; |
|||
|
|||
&#{is('dragging')} { |
|||
cursor: grabbing; |
|||
user-select: none; |
|||
touch-action: none; |
|||
} |
|||
|
|||
&#{is('no-drag')} { |
|||
#{e('handle')} { |
|||
cursor: default; |
|||
touch-action: auto; |
|||
} |
|||
} |
|||
|
|||
#{e('handle')} { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
min-height: 2rem; |
|||
cursor: grab; |
|||
touch-action: none; |
|||
flex-shrink: 0; |
|||
|
|||
&:active { |
|||
cursor: grabbing; |
|||
} |
|||
} |
|||
|
|||
#{e('handle-grip')} { |
|||
display: block; |
|||
width: 2rem; |
|||
height: 0.25rem; |
|||
border-radius: 999px; |
|||
opacity: 0.35; |
|||
} |
|||
|
|||
#{e('body')} { |
|||
box-sizing: border-box; |
|||
min-height: 0; |
|||
} |
|||
} |
|||
@ -0,0 +1,94 @@ |
|||
@use 'core/_base' as *; |
|||
@use 'sass:selector'; |
|||
|
|||
@include setNamespace('input'); |
|||
|
|||
#{b()} { |
|||
position: relative; |
|||
display: inline-flex; |
|||
width: 100%; |
|||
box-sizing: border-box; |
|||
vertical-align: middle; |
|||
&#{m('mini')} #{e('wrapper')} { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
font-size: 12px; |
|||
} |
|||
&#{m('small')} #{e('wrapper')} { |
|||
height: 36px; |
|||
line-height: 36px; |
|||
font-size: 14px; |
|||
} |
|||
&#{m('large')} #{e('wrapper')} { |
|||
height: 44px; |
|||
line-height: 44px; |
|||
font-size: 16px; |
|||
} |
|||
|
|||
#{e('wrapper')} { |
|||
width: 100%; |
|||
display: inline-flex; |
|||
font-size: 14px; |
|||
height: 40px; |
|||
line-height: 40px; |
|||
color: #606266; |
|||
transition: |
|||
border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1), |
|||
background-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); |
|||
background-color: #e9e9e9; |
|||
border: 1px solid transparent; |
|||
border-radius: 4px; |
|||
padding: 0 15px; |
|||
box-sizing: border-box; |
|||
&:not(:disabled):has(#{e('inner')}:focus) { |
|||
border-color: #007bff; |
|||
box-shadow: 0 0 3px 2px rgba(0, 123, 255, 0.4); |
|||
background-color: #fff; |
|||
} |
|||
} |
|||
|
|||
#{e('inner')} { |
|||
-webkit-appearance: none; |
|||
background-image: none; |
|||
background: none; |
|||
border: none; |
|||
box-sizing: border-box; |
|||
line-height: inherit; |
|||
color: inherit; |
|||
font-size: inherit; |
|||
outline: none; |
|||
width: 100%; |
|||
} |
|||
|
|||
#{e('prefix')} { |
|||
margin-right: 8px; |
|||
font-size: 1.5em; |
|||
} |
|||
#{e('suffix')} { |
|||
margin-left: 8px; |
|||
font-size: 1.5em; |
|||
} |
|||
|
|||
&#{is('disabled')} { |
|||
#{e('wrapper')} { |
|||
// color: #ccc; |
|||
// background-color: #e9e9e9; |
|||
opacity: 0.6; |
|||
} |
|||
#{e('inner')} { |
|||
cursor: not-allowed; |
|||
} |
|||
} |
|||
&#{is('readonly')} { |
|||
#{e('inner')} { |
|||
cursor: default; |
|||
} |
|||
position: relative; |
|||
transition: width 0.2s ease-in-out; |
|||
&#{is('hover-show')}:hover #{e('wrapper')} { |
|||
position: absolute; |
|||
transform: translateY(-50%); |
|||
width: 300px; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
@use 'core/_base' as *; |
|||
@use 'sass:selector'; |
|||
|
|||
@include setNamespace('select'); |
|||
|
|||
#{b()} { |
|||
position: relative; |
|||
display: inline-flex; |
|||
width: 100%; |
|||
box-sizing: border-box; |
|||
vertical-align: middle; |
|||
|
|||
&#{m('mini')} #{e('wrapper')} { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
font-size: 12px; |
|||
} |
|||
&#{m('small')} #{e('wrapper')} { |
|||
height: 36px; |
|||
line-height: 36px; |
|||
font-size: 14px; |
|||
} |
|||
&#{m('large')} #{e('wrapper')} { |
|||
height: 44px; |
|||
line-height: 44px; |
|||
font-size: 16px; |
|||
} |
|||
|
|||
#{e('wrapper')} { |
|||
width: 100%; |
|||
display: inline-flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
font-size: 14px; |
|||
height: 40px; |
|||
line-height: 40px; |
|||
color: #606266; |
|||
transition: |
|||
border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1), |
|||
background-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); |
|||
background-color: #e9e9e9; |
|||
border: 1px solid transparent; |
|||
border-radius: 4px; |
|||
padding: 0 15px; |
|||
box-sizing: border-box; |
|||
cursor: pointer; |
|||
|
|||
&:hover:not(:disabled) { |
|||
background-color: #fff; |
|||
} |
|||
|
|||
&:not(:disabled):has(#{e('inner')}:focus) { |
|||
border-color: #007bff; |
|||
box-shadow: 0 0 3px 2px rgba(0, 123, 255, 0.4); |
|||
background-color: #fff; |
|||
} |
|||
} |
|||
|
|||
#{e('inner')} { |
|||
flex: 1; |
|||
box-sizing: border-box; |
|||
line-height: inherit; |
|||
color: inherit; |
|||
font-size: inherit; |
|||
outline: none; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
#{e('suffix')} { |
|||
margin-left: 8px; |
|||
display: flex; |
|||
align-items: center; |
|||
color: #909399; |
|||
transition: transform 0.2s; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
#{e('dropdown')} { |
|||
position: absolute; |
|||
top: calc(100% + 4px); |
|||
left: 0; |
|||
right: 0; |
|||
z-index: 99; |
|||
display: grid; |
|||
grid-template-rows: 1fr; |
|||
transition: grid-template-rows 0.1s ease-out; |
|||
overflow: hidden; |
|||
|
|||
&#{is('hidden')} { |
|||
grid-template-rows: 0fr; |
|||
} |
|||
|
|||
#{e('dropdown-wrapper')} { |
|||
min-height: 0; |
|||
#{e('dropdown-list')} { |
|||
box-sizing: border-box; |
|||
background-color: #fff; |
|||
border: 1px solid #dcdfe6; |
|||
border-radius: 4px; |
|||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); |
|||
max-height: 274px; |
|||
overflow-y: auto; |
|||
|
|||
#{e('dropdown-item')} { |
|||
padding: 10px 15px; |
|||
font-size: 14px; |
|||
line-height: 1.5; |
|||
display: flex; |
|||
align-items: center; |
|||
cursor: pointer; |
|||
color: #606266; |
|||
|
|||
&:hover { |
|||
background-color: #f5f7fa; |
|||
} |
|||
|
|||
&#{is('selected')} { |
|||
color: #007bff; |
|||
background-color: #ecf5ff; |
|||
} |
|||
} |
|||
|
|||
#{e('dropdown-line')} { |
|||
width: 100%; |
|||
height: 1px; |
|||
background-color: #dcdfe6; |
|||
transform: scaleY(0.5); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
&#{is('disabled')} { |
|||
#{e('wrapper')} { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
#{e('inner')} { |
|||
cursor: not-allowed; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
:root { |
|||
--color-base-100: oklch(98% 0.02 240); |
|||
--color-base-200: oklch(95% 0.03 240); |
|||
--color-base-300: oklch(92% 0.04 240); |
|||
--color-base-content: oklch(20% 0.05 240); |
|||
--color-primary: oklch(55% 0.3 240); |
|||
--color-primary-content: oklch(98% 0.01 240); |
|||
--color-secondary: oklch(70% 0.25 200); |
|||
--color-secondary-content: oklch(98% 0.01 200); |
|||
--color-accent: oklch(65% 0.25 160); |
|||
--color-accent-content: oklch(98% 0.01 160); |
|||
--color-neutral: oklch(50% 0.05 240); |
|||
--color-neutral-content: oklch(98% 0.01 240); |
|||
--color-info: oklch(70% 0.2 220); |
|||
--color-info-content: oklch(98% 0.01 220); |
|||
--color-success: oklch(65% 0.25 140); |
|||
--color-success-content: oklch(98% 0.01 140); |
|||
--color-warning: oklch(80% 0.25 80); |
|||
--color-warning-content: oklch(20% 0.05 80); |
|||
--color-error: oklch(65% 0.3 30); |
|||
--color-error-content: oklch(98% 0.01 30); |
|||
|
|||
/* border radius */ |
|||
--radius-selector: 1rem; |
|||
--radius-field: 0.25rem; |
|||
--radius-box: 0.5rem; |
|||
|
|||
/* base sizes */ |
|||
--size-selector: 0.25rem; |
|||
--size-field: 0.25rem; |
|||
|
|||
/* border size */ |
|||
--border: 1px; |
|||
|
|||
/* effects */ |
|||
--depth: 1; |
|||
--noise: 0; |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
{ |
|||
"extends": "tsconfig/tsconfig.json", |
|||
"include": ["resolver.ts"], |
|||
"exclude": ["node_modules", "dist"] |
|||
} |
|||
@ -0,0 +1,127 @@ |
|||
import { computed, getCurrentInstance, inject, isRef, ref, unref } from 'vue' |
|||
|
|||
import type { InjectionKey, MaybeRef, Ref } from 'vue' |
|||
|
|||
export const defaultNamespace = 'bo' |
|||
const statePrefix = 'is-' |
|||
|
|||
const _bem = ( |
|||
namespace: string, |
|||
block: string, |
|||
blockSuffix: string, |
|||
element: string, |
|||
modifier: string |
|||
) => { |
|||
let cls = `${namespace}-${block}` |
|||
if (blockSuffix) { |
|||
cls += `-${blockSuffix}` |
|||
} |
|||
if (element) { |
|||
cls += `__${element}` |
|||
} |
|||
if (modifier) { |
|||
cls += `--${modifier}` |
|||
} |
|||
return cls |
|||
} |
|||
|
|||
export const namespaceContextKey: InjectionKey<Ref<string | undefined>> = |
|||
Symbol('namespaceContextKey') |
|||
|
|||
/** |
|||
* 获取上下文的命名空间 |
|||
* @param namespaceOverrides 覆盖命令空间 |
|||
* @returns 覆盖后的命令空间 |
|||
*/ |
|||
export const useGetDerivedNamespace = (namespaceOverrides?: Ref<string | undefined>) => { |
|||
const derivedNamespace = |
|||
namespaceOverrides || |
|||
(getCurrentInstance() |
|||
? inject(namespaceContextKey, ref(defaultNamespace)) |
|||
: ref(defaultNamespace)) |
|||
const namespace = computed(() => { |
|||
return unref(derivedNamespace) || defaultNamespace |
|||
}) |
|||
return namespace |
|||
} |
|||
|
|||
export const useNamespace = ( |
|||
block: MaybeRef<string>, |
|||
namespaceOverrides?: Ref<string | undefined> |
|||
) => { |
|||
const namespace = useGetDerivedNamespace(namespaceOverrides) |
|||
const getBlock = () => (isRef(block) ? block.value : block) |
|||
const b = (blockSuffix = '') => _bem(namespace.value, getBlock(), blockSuffix, '', '') // bo-button-test
|
|||
const e = (element?: string) => |
|||
element ? _bem(namespace.value, getBlock(), '', element, '') : '' // bo-button__test
|
|||
const m = (modifier?: string) => |
|||
modifier ? _bem(namespace.value, getBlock(), '', '', modifier) : '' // bo-button--test
|
|||
const be = (blockSuffix?: string, element?: string) => |
|||
blockSuffix && element |
|||
? _bem(namespace.value, getBlock(), blockSuffix, element, '') // bo-button-test__test
|
|||
: '' |
|||
const em = (element?: string, modifier?: string) => |
|||
element && modifier |
|||
? _bem(namespace.value, getBlock(), '', element, modifier) // bo-button__test--test
|
|||
: '' |
|||
const bm = (blockSuffix?: string, modifier?: string) => |
|||
blockSuffix && modifier |
|||
? _bem(namespace.value, getBlock(), blockSuffix, '', modifier) // bo-button-test--test
|
|||
: '' |
|||
const bem = (blockSuffix?: string, element?: string, modifier?: string) => |
|||
blockSuffix && element && modifier |
|||
? _bem(namespace.value, getBlock(), blockSuffix, element, modifier) // bo-button-test__test--test
|
|||
: '' |
|||
const is: { |
|||
(name: string, state: boolean | undefined): string |
|||
(name: string): string |
|||
} = (name: string, ...args: [boolean | undefined] | []) => { |
|||
const state = args.length >= 1 ? args[0]! : true |
|||
return name && state ? `${statePrefix}${name}` : '' // is-test
|
|||
} |
|||
|
|||
// for css var
|
|||
// --bo-xxx: value;
|
|||
const cssVar = (object: Record<string, string>) => { |
|||
const styles: Record<string, string> = {} |
|||
for (const key in object) { |
|||
if (object[key]) { |
|||
styles[`--${namespace.value}-${key}`] = object[key] // --bo-test: test
|
|||
} |
|||
} |
|||
return styles |
|||
} |
|||
// with block
|
|||
const cssVarBlock = (object: Record<string, string>) => { |
|||
const styles: Record<string, string> = {} |
|||
for (const key in object) { |
|||
if (object[key]) { |
|||
styles[`--${namespace.value}-${getBlock()}-${key}`] = object[key] // --bo-button-test: test
|
|||
} |
|||
} |
|||
return styles |
|||
} |
|||
|
|||
const cssVarName = (name: string) => `--${namespace.value}-${name}` // --bo-test
|
|||
const cssVarBlockName = (name: string) => `--${namespace.value}-${getBlock()}-${name}` // --bo-button-test
|
|||
|
|||
return { |
|||
namespace, |
|||
// bo-button-test
|
|||
b, |
|||
e, |
|||
m, |
|||
be, |
|||
em, |
|||
bm, |
|||
bem, |
|||
is, |
|||
// css
|
|||
cssVar, |
|||
cssVarName, |
|||
cssVarBlock, |
|||
cssVarBlockName |
|||
} |
|||
} |
|||
|
|||
export type UseNamespaceReturn = ReturnType<typeof useNamespace> |
|||
@ -0,0 +1,26 @@ |
|||
import type { App } from 'vue' |
|||
import type { SFCWithInstall } from './types' |
|||
import { NOOP } from './types' |
|||
|
|||
export const withNoopInstall = <T>(component: T) => { |
|||
;(component as SFCWithInstall<T>).install = NOOP |
|||
|
|||
return component as SFCWithInstall<T> |
|||
} |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
export const withInstall = <T, E extends Record<string, any>>(main: T, extra?: E) => { |
|||
;(main as SFCWithInstall<T>).install = (app: App): void => { |
|||
for (const comp of [main, ...Object.values(extra ?? {})]) { |
|||
app.component(comp.name, comp) |
|||
} |
|||
} |
|||
|
|||
if (extra) { |
|||
for (const [key, comp] of Object.entries(extra)) { |
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
;(main as any)[key] = comp |
|||
} |
|||
} |
|||
return main as SFCWithInstall<T> & E |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import type { AppContext, Plugin } from 'vue' |
|||
import type { Ref } from 'vue' |
|||
|
|||
export type SFCWithInstall<T> = T & Plugin |
|||
|
|||
export type SFCInstallWithContext<T> = SFCWithInstall<T> & { |
|||
_context: AppContext | null |
|||
} |
|||
|
|||
export const NOOP = () => {} |
|||
|
|||
export type MaybeRef<T> = T | Ref<T> |
|||
Loading…
Reference in new issue