feat: add emoji search aliases and icon catalog components
- Created `emoji-search-aliases.ts` to define aliases for emojis. - Added `icon-catalog.ts` to maintain a catalog of icons with their names and categories. - Introduced `glyph-demo.scss` for styling the glyph demo page. - Implemented routing in `index.ts` for component navigation. - Added base styles in `demo.css` for consistent layout and button styles. - Updated TypeScript configuration to include component types. - Modified Vitest configuration for improved testing setup.
parent
8c54d1f8db
commit
a50770f646
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
||||
param(
|
||||
[string]$UnicodeVersion = '17.0'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$sourceUrl = "https://www.unicode.org/Public/$UnicodeVersion.0/emoji/emoji-test.txt"
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$outputPath = Join-Path $projectRoot 'src\pages\components\emoji-catalog.ts'
|
||||
$response = Invoke-WebRequest -Uri $sourceUrl -UseBasicParsing
|
||||
|
||||
$group = ''
|
||||
$subgroup = ''
|
||||
$entries = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($rawLine in ($response.Content -split "`n")) {
|
||||
$line = $rawLine.Trim()
|
||||
|
||||
if ($line -match '^# group: (.+)$') {
|
||||
$group = $Matches[1]
|
||||
continue
|
||||
}
|
||||
|
||||
if ($line -match '^# subgroup: (.+)$') {
|
||||
$subgroup = $Matches[1]
|
||||
continue
|
||||
}
|
||||
|
||||
if ($line -match '^([0-9A-F ]+)\s*;\s*(?:fully-qualified|component)\s*#\s*(\S+)\s+E([0-9.]+)\s+(.+)$') {
|
||||
$entries.Add([PSCustomObject]@{
|
||||
emoji = $Matches[2]
|
||||
name = $Matches[4]
|
||||
group = $group
|
||||
subgroup = $subgroup
|
||||
version = $Matches[3]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$lines = [System.Collections.Generic.List[string]]::new()
|
||||
$lines.Add('// Generated from the official Unicode emoji-test.txt data file.')
|
||||
$lines.Add("// Unicode Emoji version: $UnicodeVersion")
|
||||
$lines.Add("// Source: $sourceUrl")
|
||||
$lines.Add('// License: https://www.unicode.org/license.txt')
|
||||
$lines.Add('')
|
||||
$lines.Add('export interface EmojiCatalogEntry {')
|
||||
$lines.Add(' emoji: string')
|
||||
$lines.Add(' name: string')
|
||||
$lines.Add(' group: string')
|
||||
$lines.Add(' subgroup: string')
|
||||
$lines.Add(' version: string')
|
||||
$lines.Add('}')
|
||||
$lines.Add('')
|
||||
$lines.Add("export const emojiCatalogVersion = '$UnicodeVersion'")
|
||||
$lines.Add('')
|
||||
$lines.Add('export const emojiCatalog: EmojiCatalogEntry[] = [')
|
||||
|
||||
foreach ($entry in $entries) {
|
||||
$emoji = ConvertTo-Json $entry.emoji -Compress
|
||||
$name = ConvertTo-Json $entry.name -Compress
|
||||
$entryGroup = ConvertTo-Json $entry.group -Compress
|
||||
$entrySubgroup = ConvertTo-Json $entry.subgroup -Compress
|
||||
$version = ConvertTo-Json $entry.version -Compress
|
||||
$lines.Add(" { emoji: $emoji, name: $name, group: $entryGroup, subgroup: $entrySubgroup, version: $version },")
|
||||
}
|
||||
|
||||
$lines.Add(']')
|
||||
$lines.Add('')
|
||||
|
||||
Set-Content -LiteralPath $outputPath -Value $lines -Encoding utf8
|
||||
Write-Output "Generated $($entries.Count) emoji entries at $outputPath"
|
||||
@ -1,11 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import App from '../App.vue'
|
||||
|
||||
describe('App', () => {
|
||||
it('mounts renders properly', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.text()).toContain('You did it!')
|
||||
it('renders the active route', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<h1>Concise UI</h1>' } }],
|
||||
})
|
||||
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(App, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Concise UI')
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
tag?: string
|
||||
}>(),
|
||||
{
|
||||
tag: 'div',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="tag" class="c-app-bar">
|
||||
<div v-if="$slots.start" class="section start">
|
||||
<slot name="start" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.center" class="section center">
|
||||
<slot name="center" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.end" class="section end">
|
||||
<slot name="end" />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.c-app-bar {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--c-app-bar-height, 38px);
|
||||
padding: var(--c-app-bar-padding, 3px 8px);
|
||||
color: var(--c-text-color, #20242a);
|
||||
background: var(--c-surface-color, #fff);
|
||||
border-bottom: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: var(--c-app-bar-gap, 6px);
|
||||
}
|
||||
|
||||
.start {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.center {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.end {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import CIcon from '../icon/CIcon.vue'
|
||||
import type { CButtonSize, CButtonType, CButtonVariant } from './types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type?: CButtonType
|
||||
variant?: CButtonVariant
|
||||
size?: CButtonSize
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
type: 'button',
|
||||
variant: 'default',
|
||||
size: 'medium',
|
||||
icon: undefined,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
ariaLabel: undefined,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="c-button"
|
||||
:class="[
|
||||
`is-${variant}`,
|
||||
`is-${size}`,
|
||||
{ 'is-loading': loading, 'is-icon-only': !$slots.default },
|
||||
]"
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
>
|
||||
<span v-if="loading" class="icon" aria-hidden="true">
|
||||
<CIcon :rotate="1">↻</CIcon>
|
||||
</span>
|
||||
<span v-else-if="icon || $slots.icon" class="icon" aria-hidden="true">
|
||||
<slot name="icon">
|
||||
<CIcon>{{ icon }}</CIcon>
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="$slots.default" class="label"><slot /></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.c-button {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: var(--c-control-height, 30px);
|
||||
padding: 4px 10px;
|
||||
gap: 6px;
|
||||
color: var(--c-text-color, #20242a);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 1px solid var(--c-control-border-color, #bfc5ce);
|
||||
border-radius: var(--c-border-radius, 3px);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--c-hover-color, #eef1f5);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: var(--c-active-color, #e1e5ea);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--c-focus-color, #3578c6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--c-disabled-text-color, #8a9099);
|
||||
cursor: not-allowed;
|
||||
background: var(--c-disabled-background-color, #f1f3f5);
|
||||
border-color: var(--c-disabled-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
&.is-primary {
|
||||
color: var(--c-primary-text-color, #fff);
|
||||
background: var(--c-primary-color, #286aa6);
|
||||
border-color: var(--c-primary-border-color, #245f95);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--c-primary-hover-color, #245f95);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: var(--c-primary-active-color, #1f5687);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
color: var(--c-success-text-color, #fff);
|
||||
background: var(--c-success-color, #2f7d32);
|
||||
border-color: var(--c-success-border-color, #27692a);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--c-success-hover-color, #27692a);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: var(--c-success-active-color, #205923);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: var(--c-warning-text-color, #3d2a00);
|
||||
background: var(--c-warning-color, #f0ad32);
|
||||
border-color: var(--c-warning-border-color, #d7951d);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--c-warning-hover-color, #e3a025);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: var(--c-warning-active-color, #cf8d18);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: var(--c-danger-text-color, #fff);
|
||||
background: var(--c-danger-color, #b42318);
|
||||
border-color: var(--c-danger-border-color, #991b1b);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--c-danger-hover-color, #991b1b);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: var(--c-danger-active-color, #7f1d1d);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-primary:disabled,
|
||||
&.is-success:disabled,
|
||||
&.is-warning:disabled,
|
||||
&.is-danger:disabled {
|
||||
color: var(--c-disabled-text-color, #8a9099);
|
||||
background: var(--c-disabled-background-color, #f1f3f5);
|
||||
border-color: var(--c-disabled-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
&.is-icon-only {
|
||||
width: var(--c-control-height, 30px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
min-height: 26px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
|
||||
&.is-icon-only {
|
||||
width: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-large {
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
font-size: 14px;
|
||||
|
||||
&.is-icon-only {
|
||||
width: 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,3 @@
|
||||
export type CButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger'
|
||||
export type CButtonType = 'button' | 'submit' | 'reset'
|
||||
export type CButtonSize = 'small' | 'medium' | 'large'
|
||||
@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isGlyphSize } from './types'
|
||||
import type { CGlyphDisplay, CGlyphSize } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
display?: CGlyphDisplay
|
||||
size?: CGlyphSize
|
||||
rotate?: number
|
||||
label?: string
|
||||
}>(),
|
||||
{
|
||||
display: 'inline',
|
||||
size: undefined,
|
||||
rotate: 0,
|
||||
label: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const isRotating = computed(() => Number.isFinite(props.rotate) && props.rotate !== 0)
|
||||
|
||||
const glyphStyle = computed<Record<string, string>>(() => {
|
||||
const style: Record<string, string> = {}
|
||||
|
||||
if (isGlyphSize(props.size)) style['--c-glyph-size'] = props.size
|
||||
|
||||
if (isRotating.value) {
|
||||
style['--c-glyph-rotation-duration'] = `${1 / Math.abs(props.rotate)}s`
|
||||
style['--c-glyph-rotation-direction'] = props.rotate < 0 ? 'reverse' : 'normal'
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="c-glyph"
|
||||
:class="[`is-${display}`, { 'is-rotating': isRotating }]"
|
||||
:style="glyphStyle"
|
||||
:role="label ? 'img' : undefined"
|
||||
:aria-label="label"
|
||||
:aria-hidden="label ? undefined : 'true'"
|
||||
>
|
||||
<span class="content"><slot /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.c-glyph {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--c-glyph-size, 1em);
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
vertical-align: -0.125em;
|
||||
|
||||
&.is-inline {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&.is-circle {
|
||||
width: 1.75em;
|
||||
height: 1.75em;
|
||||
background: var(--c-glyph-circle-background, #eef1f5);
|
||||
border: 1px solid var(--c-glyph-circle-border-color, #d5d9df);
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
&.is-rotating .content {
|
||||
animation: c-glyph-rotate var(--c-glyph-rotation-duration) linear infinite;
|
||||
animation-direction: var(--c-glyph-rotation-direction);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes c-glyph-rotate {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-glyph.is-rotating .content {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,14 @@
|
||||
export type CGlyphDisplay = 'inline' | 'circle'
|
||||
export type CGlyphSizeUnit = 'px' | 'em' | 'rem'
|
||||
export type CGlyphSize = `${number}${CGlyphSizeUnit}`
|
||||
|
||||
export interface CGlyphPresentationProps {
|
||||
display?: CGlyphDisplay
|
||||
size?: CGlyphSize
|
||||
rotate?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function isGlyphSize(value: unknown): value is CGlyphSize {
|
||||
return typeof value === 'string' && /^(?:0|[1-9]\d*)(?:\.\d+)?(?:px|em|rem)$/.test(value)
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import CGlyph from '../glyph/CGlyph.vue'
|
||||
import type { CGlyphDisplay, CGlyphSize } from '../glyph/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
display?: CGlyphDisplay
|
||||
size?: CGlyphSize
|
||||
rotate?: number
|
||||
color?: string
|
||||
label?: string
|
||||
}>(),
|
||||
{
|
||||
display: 'inline',
|
||||
size: undefined,
|
||||
rotate: 0,
|
||||
color: undefined,
|
||||
label: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const iconStyle = computed(() => (props.color ? { color: props.color } : undefined))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CGlyph
|
||||
class="c-icon"
|
||||
:display="display"
|
||||
:size="size"
|
||||
:rotate="rotate"
|
||||
:label="label"
|
||||
:style="iconStyle"
|
||||
>
|
||||
<slot />
|
||||
</CGlyph>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.c-icon {
|
||||
color: inherit;
|
||||
font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", "Segoe UI Symbol",
|
||||
"Noto Sans Symbols 2", system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import CMenuList from './CMenuList.vue'
|
||||
import type {
|
||||
CMenuEntry,
|
||||
CMenuOrientation,
|
||||
CMenuSelectEvent,
|
||||
CMenuSubmenuMode,
|
||||
} from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: CMenuEntry[]
|
||||
orientation?: CMenuOrientation
|
||||
submenuMode?: CMenuSubmenuMode
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
orientation: 'vertical',
|
||||
submenuMode: 'auto',
|
||||
ariaLabel: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [event: CMenuSelectEvent]
|
||||
}>()
|
||||
|
||||
const expandedKeys = ref<Set<string>>(new Set())
|
||||
|
||||
const resolvedSubmenuMode = computed<'inline' | 'flyout'>(() => {
|
||||
if (props.submenuMode !== 'auto') return props.submenuMode
|
||||
return props.orientation === 'horizontal' ? 'flyout' : 'inline'
|
||||
})
|
||||
|
||||
function parentKey(key: string) {
|
||||
const separatorIndex = key.lastIndexOf('/')
|
||||
return separatorIndex === -1 ? '' : key.slice(0, separatorIndex)
|
||||
}
|
||||
|
||||
function toggleItem(key: string, force?: boolean) {
|
||||
const next = new Set(expandedKeys.value)
|
||||
const shouldExpand = force ?? !next.has(key)
|
||||
|
||||
if (shouldExpand) {
|
||||
if (resolvedSubmenuMode.value === 'flyout') {
|
||||
const siblingParent = parentKey(key)
|
||||
|
||||
for (const expandedKey of next) {
|
||||
if (parentKey(expandedKey) === siblingParent && expandedKey !== key) {
|
||||
next.delete(expandedKey)
|
||||
|
||||
for (const descendantKey of next) {
|
||||
if (descendantKey.startsWith(`${expandedKey}/`)) next.delete(descendantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.add(key)
|
||||
} else {
|
||||
next.delete(key)
|
||||
|
||||
for (const expandedKey of next) {
|
||||
if (expandedKey.startsWith(`${key}/`)) next.delete(expandedKey)
|
||||
}
|
||||
}
|
||||
|
||||
expandedKeys.value = next
|
||||
}
|
||||
|
||||
function selectItem(event: CMenuSelectEvent) {
|
||||
expandedKeys.value = new Set()
|
||||
emit('select', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CMenuList
|
||||
:items="items"
|
||||
:orientation="orientation"
|
||||
:submenu-mode="resolvedSubmenuMode"
|
||||
:expanded-keys="expandedKeys"
|
||||
:aria-label="ariaLabel"
|
||||
@toggle="toggleItem"
|
||||
@select="selectItem"
|
||||
>
|
||||
<template v-if="$slots.item" #item="slotProps">
|
||||
<slot name="item" v-bind="slotProps" />
|
||||
</template>
|
||||
</CMenuList>
|
||||
</template>
|
||||
@ -0,0 +1,31 @@
|
||||
export type CMenuOrientation = 'horizontal' | 'vertical'
|
||||
export type CMenuSubmenuMode = 'auto' | 'inline' | 'flyout'
|
||||
|
||||
export interface CMenuCommandContext {
|
||||
item: CMenuActionItem
|
||||
originalEvent: MouseEvent
|
||||
}
|
||||
|
||||
export interface CMenuActionItem {
|
||||
type?: 'item'
|
||||
id?: string
|
||||
label: string
|
||||
icon?: string
|
||||
url?: string
|
||||
target?: string
|
||||
children?: CMenuEntry[]
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
active?: boolean
|
||||
command?: (context: CMenuCommandContext) => void
|
||||
}
|
||||
|
||||
export interface CMenuSeparatorItem {
|
||||
type: 'separator'
|
||||
id?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type CMenuEntry = CMenuActionItem | CMenuSeparatorItem
|
||||
|
||||
export interface CMenuSelectEvent extends CMenuCommandContext {}
|
||||
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { CSeparatorOrientation } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
orientation?: CSeparatorOrientation
|
||||
decorative?: boolean
|
||||
}>(),
|
||||
{
|
||||
orientation: 'horizontal',
|
||||
decorative: false,
|
||||
},
|
||||
)
|
||||
|
||||
const separatorRole = computed(() => (props.decorative ? undefined : 'separator'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="c-separator"
|
||||
:class="`is-${orientation}`"
|
||||
:role="separatorRole"
|
||||
:aria-hidden="decorative ? 'true' : undefined"
|
||||
:aria-orientation="decorative ? undefined : orientation"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.c-separator {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
border: 0 solid var(--c-border-color, #d5d9df);
|
||||
|
||||
&.is-horizontal {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
&.is-vertical {
|
||||
align-self: stretch;
|
||||
width: 1px;
|
||||
min-height: 1rem;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1 @@
|
||||
export type CSeparatorOrientation = 'horizontal' | 'vertical'
|
||||
@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import CButton from '../button/CButton.vue'
|
||||
import type { CSideBarPlacement } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tag?: string
|
||||
placement?: CSideBarPlacement
|
||||
width?: string
|
||||
collapsible?: boolean
|
||||
collapsed?: boolean
|
||||
collapsedWidth?: string
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
tag: 'aside',
|
||||
placement: 'start',
|
||||
width: 'var(--c-side-bar-width, 240px)',
|
||||
collapsible: false,
|
||||
collapsed: undefined,
|
||||
collapsedWidth: '38px',
|
||||
ariaLabel: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:collapsed': [collapsed: boolean]
|
||||
}>()
|
||||
|
||||
const internalCollapsed = ref(false)
|
||||
const isCollapsed = computed(() =>
|
||||
props.collapsible ? (props.collapsed ?? internalCollapsed.value) : false,
|
||||
)
|
||||
|
||||
const sideBarStyle = computed(() => ({
|
||||
'--c-side-bar-current-width': isCollapsed.value ? props.collapsedWidth : props.width,
|
||||
}))
|
||||
|
||||
watch(
|
||||
() => props.collapsed,
|
||||
(collapsed) => {
|
||||
if (collapsed !== undefined) internalCollapsed.value = collapsed
|
||||
},
|
||||
)
|
||||
|
||||
function toggleCollapsed() {
|
||||
const collapsed = !isCollapsed.value
|
||||
internalCollapsed.value = collapsed
|
||||
emit('update:collapsed', collapsed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="tag"
|
||||
class="c-side-bar"
|
||||
:class="[`is-${placement}`, { 'is-collapsed': isCollapsed }]"
|
||||
:style="sideBarStyle"
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<div v-if="$slots.header || collapsible" class="header">
|
||||
<CButton
|
||||
v-if="collapsible"
|
||||
class="toggle"
|
||||
icon="☰"
|
||||
:aria-expanded="!isCollapsed"
|
||||
:aria-label="isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
@click="toggleCollapsed"
|
||||
/>
|
||||
|
||||
<div v-show="!isCollapsed && $slots.header" class="header-content">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed" class="content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" v-show="!isCollapsed" class="footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.c-side-bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 0 0 var(--c-side-bar-current-width);
|
||||
flex-direction: column;
|
||||
width: var(--c-side-bar-current-width);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
color: var(--c-text-color, #20242a);
|
||||
font-family: var(--c-font-family, system-ui, -apple-system, "Segoe UI", sans-serif);
|
||||
font-size: var(--c-font-size, 13px);
|
||||
background: var(--c-surface-color, #fff);
|
||||
transition:
|
||||
width var(--c-side-bar-transition-duration, 160ms) ease,
|
||||
flex-basis var(--c-side-bar-transition-duration, 160ms) ease;
|
||||
|
||||
&.is-start {
|
||||
border-inline-end: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
&.is-end {
|
||||
border-inline-start: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
min-height: var(--c-side-bar-section-height, 38px);
|
||||
padding: var(--c-side-bar-section-padding, 5px 8px);
|
||||
gap: var(--c-side-bar-gap, 6px);
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
&.is-collapsed .header {
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: var(--c-side-bar-content-padding, 4px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-side-bar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1 @@
|
||||
export type CSideBarPlacement = 'start' | 'end'
|
||||
@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import Prism from 'prismjs'
|
||||
import 'prismjs/components/prism-typescript'
|
||||
|
||||
type CCodeLanguage = 'markup' | 'css' | 'javascript' | 'typescript'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
code: string
|
||||
language?: CCodeLanguage
|
||||
wrap?: boolean
|
||||
}>(),
|
||||
{
|
||||
language: 'markup',
|
||||
wrap: false,
|
||||
},
|
||||
)
|
||||
|
||||
const copyStatus = ref('')
|
||||
let copyStatusTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const highlightedCode = computed(() => {
|
||||
const grammar = Prism.languages[props.language] ?? Prism.languages.markup!
|
||||
return Prism.highlight(props.code, grammar, props.language)
|
||||
})
|
||||
|
||||
async function copyCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.code)
|
||||
copyStatus.value = 'Copied'
|
||||
} catch {
|
||||
copyStatus.value = 'Copy failed'
|
||||
}
|
||||
|
||||
if (copyStatusTimer) clearTimeout(copyStatusTimer)
|
||||
copyStatusTimer = setTimeout(() => {
|
||||
copyStatus.value = ''
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyStatusTimer) clearTimeout(copyStatusTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-block" :class="{ 'is-wrapped': wrap }">
|
||||
<div class="header">
|
||||
<span>{{ language }}</span>
|
||||
<button type="button" @click="copyCode">{{ copyStatus || 'Copy' }}</button>
|
||||
</div>
|
||||
|
||||
<pre><code :class="`language-${language}`" v-html="highlightedCode" /></pre>
|
||||
<span class="status" aria-live="polite">{{ copyStatus }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.code-block {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #24292f;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
border-radius: var(--c-border-radius, 3px);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
padding: 3px 5px 3px 9px;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
background: #eef1f5;
|
||||
border-bottom: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
button {
|
||||
min-width: 52px;
|
||||
height: 22px;
|
||||
padding: 2px 7px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #c7ccd3;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
color: #20242a;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--c-focus-color, #3578c6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 11px 13px;
|
||||
overflow: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
font-family: Consolas, "Cascadia Code", "SFMono-Regular", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-shadow: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
&.is-wrapped code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
:deep(.token) {
|
||||
&.comment,
|
||||
&.prolog,
|
||||
&.doctype,
|
||||
&.cdata {
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
&.punctuation {
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
&.tag,
|
||||
&.constant,
|
||||
&.symbol,
|
||||
&.deleted {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
&.boolean,
|
||||
&.number {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
&.selector,
|
||||
&.attr-name,
|
||||
&.string,
|
||||
&.char,
|
||||
&.builtin,
|
||||
&.inserted {
|
||||
color: #116329;
|
||||
}
|
||||
|
||||
&.operator,
|
||||
&.entity,
|
||||
&.url {
|
||||
color: #0a3069;
|
||||
}
|
||||
|
||||
&.atrule,
|
||||
&.attr-value,
|
||||
&.keyword {
|
||||
color: #8250df;
|
||||
}
|
||||
|
||||
&.function,
|
||||
&.class-name {
|
||||
color: #953800;
|
||||
}
|
||||
|
||||
&.regex,
|
||||
&.important,
|
||||
&.variable {
|
||||
color: #bc4c00;
|
||||
}
|
||||
|
||||
&.bold,
|
||||
&.important {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.language-css .token.string) {
|
||||
color: #0a3069;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,2 +1,25 @@
|
||||
// Public exports for Concise UI belong here.
|
||||
export {}
|
||||
export { default as CAppBar } from './components/app-bar/CAppBar.vue'
|
||||
export { default as CButton } from './components/button/CButton.vue'
|
||||
export { default as CIcon } from './components/icon/CIcon.vue'
|
||||
export { default as CMenu } from './components/menu/CMenu.vue'
|
||||
export { default as CSeparator } from './components/separator/CSeparator.vue'
|
||||
export { default as CSideBar } from './components/side-bar/CSideBar.vue'
|
||||
|
||||
export type {
|
||||
CMenuActionItem,
|
||||
CMenuCommandContext,
|
||||
CMenuEntry,
|
||||
CMenuOrientation,
|
||||
CMenuSelectEvent,
|
||||
CMenuSeparatorItem,
|
||||
CMenuSubmenuMode,
|
||||
} from './components/menu/types'
|
||||
export type { CButtonSize, CButtonType, CButtonVariant } from './components/button/types'
|
||||
export type { CSeparatorOrientation } from './components/separator/types'
|
||||
export type { CSideBarPlacement } from './components/side-bar/types'
|
||||
export type {
|
||||
CGlyphDisplay,
|
||||
CGlyphPresentationProps,
|
||||
CGlyphSize,
|
||||
CGlyphSizeUnit,
|
||||
} from './components/glyph/types'
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { CAppBar, CMenu, CSideBar } from '@/index'
|
||||
|
||||
import { useComponentNavigation, useTopNavigation } from './useLayoutNavigation'
|
||||
|
||||
const navigation = useTopNavigation()
|
||||
const componentNavigation = useComponentNavigation()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-layout">
|
||||
<CAppBar tag="header" aria-label="Application header">
|
||||
<template #start>
|
||||
<RouterLink class="demo-layout__brand" to="/">Concise UI</RouterLink>
|
||||
<CMenu :items="navigation" orientation="horizontal" aria-label="Primary navigation" />
|
||||
</template>
|
||||
</CAppBar>
|
||||
|
||||
<div class="demo-layout__workspace">
|
||||
<CSideBar width="220px" aria-label="Component navigation">
|
||||
<template #header>
|
||||
<strong>Components</strong>
|
||||
</template>
|
||||
|
||||
<CMenu
|
||||
:items="componentNavigation"
|
||||
orientation="vertical"
|
||||
submenu-mode="inline"
|
||||
aria-label="Component pages"
|
||||
/>
|
||||
</CSideBar>
|
||||
|
||||
<main class="demo-layout__main demo-layout__main--with-sidebar">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { CAppBar, CMenu } from '@/index'
|
||||
|
||||
import { useTopNavigation } from './useLayoutNavigation'
|
||||
|
||||
const navigation = useTopNavigation()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-layout">
|
||||
<CAppBar tag="header" aria-label="Application header">
|
||||
<template #start>
|
||||
<RouterLink class="demo-layout__brand" to="/">Concise UI</RouterLink>
|
||||
<CMenu :items="navigation" orientation="horizontal" aria-label="Primary navigation" />
|
||||
</template>
|
||||
</CAppBar>
|
||||
|
||||
<main class="demo-layout__main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,107 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type { CMenuEntry } from '@/index'
|
||||
|
||||
export function useTopNavigation() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
return computed<CMenuEntry[]>(() => [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
icon: '⌂',
|
||||
active: route.name === 'home',
|
||||
command: () => void router.push({ name: 'home' }),
|
||||
},
|
||||
{
|
||||
id: 'components',
|
||||
label: 'Components',
|
||||
icon: '▦',
|
||||
active: route.path.startsWith('/components'),
|
||||
children: [
|
||||
{
|
||||
id: 'app-bar-menu',
|
||||
label: 'AppBar & Menu',
|
||||
icon: '☰',
|
||||
active: route.name === 'app-bar-menu',
|
||||
command: () => void router.push({ name: 'app-bar-menu' }),
|
||||
},
|
||||
{
|
||||
id: 'sidebar',
|
||||
label: 'Sidebar',
|
||||
icon: '◧',
|
||||
active: route.name === 'sidebar',
|
||||
command: () => void router.push({ name: 'sidebar' }),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
id: 'button',
|
||||
label: 'Button',
|
||||
icon: '▭',
|
||||
active: route.name === 'button',
|
||||
command: () => void router.push({ name: 'button' }),
|
||||
},
|
||||
{
|
||||
id: 'icon',
|
||||
label: 'Icon',
|
||||
icon: '☆',
|
||||
active: route.name === 'icon',
|
||||
command: () => void router.push({ name: 'icon' }),
|
||||
},
|
||||
{
|
||||
id: 'emoji',
|
||||
label: 'Emoji',
|
||||
icon: '☺︎',
|
||||
active: route.name === 'emoji',
|
||||
command: () => void router.push({ name: 'emoji' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function useComponentNavigation() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
return computed<CMenuEntry[]>(() => [
|
||||
{
|
||||
id: 'app-bar-menu',
|
||||
label: 'AppBar & Menu',
|
||||
icon: '☰',
|
||||
active: route.name === 'app-bar-menu',
|
||||
command: () => void router.push({ name: 'app-bar-menu' }),
|
||||
},
|
||||
{
|
||||
id: 'sidebar',
|
||||
label: 'Sidebar',
|
||||
icon: '◧',
|
||||
active: route.name === 'sidebar',
|
||||
command: () => void router.push({ name: 'sidebar' }),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
id: 'button',
|
||||
label: 'Button',
|
||||
icon: '▭',
|
||||
active: route.name === 'button',
|
||||
command: () => void router.push({ name: 'button' }),
|
||||
},
|
||||
{
|
||||
id: 'icon',
|
||||
label: 'Icon',
|
||||
icon: '☆',
|
||||
active: route.name === 'icon',
|
||||
command: () => void router.push({ name: 'icon' }),
|
||||
},
|
||||
{
|
||||
id: 'emoji',
|
||||
label: 'Emoji',
|
||||
icon: '☺︎',
|
||||
active: route.name === 'emoji',
|
||||
command: () => void router.push({ name: 'emoji' }),
|
||||
},
|
||||
])
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/demo.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<article class="home-page">
|
||||
<p class="eyebrow">Vue 3 component framework</p>
|
||||
<h1>Concise UI</h1>
|
||||
<p class="intro">
|
||||
Compact, predictable components for desktop enterprise and productivity applications.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<RouterLink class="demo-button demo-button--primary" to="/components/app-bar-menu">
|
||||
Browse components
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<section class="principles" aria-label="Design principles">
|
||||
<div>
|
||||
<strong>Dense</strong>
|
||||
<span>Controls and layouts use space deliberately.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Professional</strong>
|
||||
<span>Neutral surfaces let application data stay prominent.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Keyboard-friendly</strong>
|
||||
<span>Desktop productivity is treated as a first-class concern.</span>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 24px;
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--c-primary-color, #2f6fad);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.intro {
|
||||
max-width: 580px;
|
||||
margin: 12px 0 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 48px;
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
gap: 5px;
|
||||
|
||||
+ div {
|
||||
border-inline-start: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,412 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { CAppBar, CButton, CMenu, CSeparator } from '@/index'
|
||||
import type { CMenuEntry, CMenuSelectEvent } from '@/index'
|
||||
import CCodeBlock from '@/documentation/CCodeBlock.vue'
|
||||
|
||||
const lastAction = ref('No command selected')
|
||||
|
||||
const appBarUsage = `<CAppBar>
|
||||
<template #start>
|
||||
<CMenu :items="mainMenu" orientation="horizontal" />
|
||||
</template>
|
||||
|
||||
<template #center>Order Management</template>
|
||||
|
||||
<template #end>
|
||||
<input type="search" placeholder="Search" />
|
||||
<CButton variant="primary">Create order</CButton>
|
||||
</template>
|
||||
</CAppBar>`
|
||||
|
||||
const appBarJavaScript = `const mainMenu = [
|
||||
{
|
||||
label: 'File',
|
||||
icon: '▤',
|
||||
children: [
|
||||
{ label: 'New', icon: '+', command: () => createDocument() },
|
||||
{ label: 'Open…', icon: '📂', command: () => openDocument() },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Save', icon: '💾', command: () => saveDocument() },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: '✎',
|
||||
children: [
|
||||
{ label: 'Undo', icon: '↶', command: () => undo() },
|
||||
{ label: 'Redo', icon: '↷', command: () => redo(), disabled: true },
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Help', icon: '?', url: '#help' },
|
||||
]`
|
||||
|
||||
const itemStructureJavaScript = `const fileMenu = [
|
||||
{
|
||||
label: 'File',
|
||||
icon: '▤',
|
||||
children: [
|
||||
{ label: 'Open…', icon: '📂', command: () => openDocument() },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Save', icon: '💾', command: () => saveDocument() },
|
||||
],
|
||||
},
|
||||
]`
|
||||
|
||||
const disabledAndHiddenJavaScript = `import { computed } from 'vue'
|
||||
|
||||
const editMenu = computed(() => [
|
||||
{
|
||||
label: 'Redo',
|
||||
icon: '↷',
|
||||
disabled: !canRedo.value,
|
||||
command: () => redo(),
|
||||
},
|
||||
{
|
||||
label: 'Administration',
|
||||
icon: '⚙︎',
|
||||
hidden: !currentUser.value.isAdministrator,
|
||||
url: '/administration',
|
||||
},
|
||||
])`
|
||||
|
||||
const linkStateJavaScript = `import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const navigation = computed(() => [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: '⌂',
|
||||
url: '/dashboard',
|
||||
active: route.path === '/dashboard',
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
icon: '?',
|
||||
url: 'https://example.com/docs',
|
||||
target: '_blank',
|
||||
},
|
||||
])`
|
||||
|
||||
const topMenu: CMenuEntry[] = [
|
||||
{
|
||||
label: 'File',
|
||||
icon: '▤',
|
||||
children: [
|
||||
{ label: 'New', icon: '+' },
|
||||
{ label: 'Open…', icon: '📂' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Save', icon: '💾' },
|
||||
{ label: 'Save As…', icon: '💾' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: '✎',
|
||||
children: [
|
||||
{ label: 'Undo', icon: '↶' },
|
||||
{ label: 'Redo', icon: '↷', disabled: true },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Preferences', icon: '⚙︎' },
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Help', icon: '?', url: '#help' },
|
||||
]
|
||||
|
||||
function recordSelection(event: CMenuSelectEvent) {
|
||||
lastAction.value = event.item.label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article id="help" class="component-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="category">Navigation</p>
|
||||
<h1>AppBar & Menu</h1>
|
||||
</div>
|
||||
<p>
|
||||
Compose application-level regions with <code>CAppBar</code>, then place data-driven
|
||||
horizontal or vertical menus where the workflow needs them.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CSeparator />
|
||||
|
||||
<section class="example">
|
||||
<div class="description">
|
||||
<h2>Application bar</h2>
|
||||
<p>A horizontal menu can share the bar with centered context and controls at the end.</p>
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<CAppBar aria-label="Example application bar">
|
||||
<template #start>
|
||||
<strong class="brand">Operations</strong>
|
||||
<CMenu
|
||||
:items="topMenu"
|
||||
orientation="horizontal"
|
||||
aria-label="Example application menu"
|
||||
@select="recordSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<span class="context">Order Management</span>
|
||||
</template>
|
||||
|
||||
<template #end>
|
||||
<input class="input" type="search" aria-label="Search orders" placeholder="Search" />
|
||||
<CButton variant="primary">Create order</CButton>
|
||||
</template>
|
||||
</CAppBar>
|
||||
<div class="status" aria-live="polite">Last action: {{ lastAction }}</div>
|
||||
</div>
|
||||
<CCodeBlock class="code-sample" :code="appBarUsage" />
|
||||
<CCodeBlock class="code-sample" :code="appBarJavaScript" language="javascript" />
|
||||
</section>
|
||||
|
||||
<section class="example">
|
||||
<div class="description">
|
||||
<h2>Item content and hierarchy</h2>
|
||||
<p>
|
||||
Each regular menu entry has a label and can include a Unicode icon or a nested menu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="property-list">
|
||||
<div>
|
||||
<dt><code>label</code></dt>
|
||||
<dd>The required text displayed for the menu item.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>icon</code></dt>
|
||||
<dd>
|
||||
An optional Unicode symbol or emoji rendered with <code>CIcon</code> before the label.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>children</code></dt>
|
||||
<dd>
|
||||
An optional array of menu entries. Providing children turns the item into a submenu
|
||||
trigger. Children can contain regular items, deeper submenus, and separators.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<CCodeBlock class="code-sample" :code="itemStructureJavaScript" language="javascript" />
|
||||
</section>
|
||||
|
||||
<section class="example">
|
||||
<div class="description">
|
||||
<h2>Disabled and hidden items</h2>
|
||||
<p>
|
||||
Set <code>disabled</code> when an action is currently unavailable. The item remains
|
||||
visible but cannot be activated, is skipped by menu keyboard navigation, and its command
|
||||
will not run. Use <code>hidden</code> when an item should not be shown at all.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="property-list">
|
||||
<div>
|
||||
<dt><code>disabled</code></dt>
|
||||
<dd>
|
||||
Applies disabled styling and <code>aria-disabled="true"</code>. It is commonly derived
|
||||
from application state, permissions, or whether an operation can currently run.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>hidden</code></dt>
|
||||
<dd>
|
||||
Removes the item from rendering and keyboard navigation. It is commonly derived from
|
||||
permissions, feature availability, or application context, and also works on
|
||||
separators.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<CCodeBlock
|
||||
class="code-sample"
|
||||
:code="disabledAndHiddenJavaScript"
|
||||
language="javascript"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="example">
|
||||
<div class="description">
|
||||
<h2>Link state and targets</h2>
|
||||
<p>
|
||||
Menu entries with a <code>url</code> render as links. Use <code>target</code> to choose
|
||||
where a link opens and <code>active</code> to identify the current page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="property-list">
|
||||
<div>
|
||||
<dt><code>url</code></dt>
|
||||
<dd>
|
||||
Becomes the link's <code>href</code>. Without it, the item renders as a button for a
|
||||
command or submenu.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>target</code></dt>
|
||||
<dd>
|
||||
Becomes the link's HTML <code>target</code>. Omit it for the current tab
|
||||
(<code>_self</code>), or use <code>_blank</code> for a new tab. It has no effect without
|
||||
<code>url</code>.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>active</code></dt>
|
||||
<dd>
|
||||
Applies current-page styling and <code>aria-current="page"</code>. Set it from the
|
||||
router or application state; <code>CMenu</code> does not match routes automatically.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<CCodeBlock class="code-sample" :code="linkStateJavaScript" language="javascript" />
|
||||
</section>
|
||||
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.component-page {
|
||||
width: min(100%, 1100px);
|
||||
margin: 0 auto;
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
|
||||
align-items: end;
|
||||
padding-bottom: 20px;
|
||||
gap: 32px;
|
||||
|
||||
> p {
|
||||
margin-bottom: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.category {
|
||||
margin: 0 0 4px;
|
||||
color: var(--c-primary-color, #2f6fad);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.example {
|
||||
padding: 22px 0;
|
||||
|
||||
.description {
|
||||
margin-bottom: 10px;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow: visible;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 6px 9px;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
border-top: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.code-sample {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.property-list {
|
||||
margin: 0;
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: 100px minmax(0, 1fr);
|
||||
|
||||
+ div {
|
||||
border-top: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 600;
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
border-inline-end: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
dd {
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding-inline-end: 4px;
|
||||
}
|
||||
|
||||
.context {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
padding: 4px 7px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-border-color, #bfc5ce);
|
||||
border-radius: var(--c-border-radius, 3px);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--c-focus-color, #3578c6);
|
||||
outline: 1px solid var(--c-focus-color, #3578c6);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { CButton, CIcon, CSeparator } from '@/index'
|
||||
import CCodeBlock from '@/documentation/CCodeBlock.vue'
|
||||
|
||||
const clickCount = ref(0)
|
||||
|
||||
const basicUsage = `<CButton @click="save">Save</CButton>
|
||||
<CButton variant="primary" @click="submit">Submit</CButton>
|
||||
<CButton variant="success" @click="approve">Approve</CButton>
|
||||
<CButton variant="warning" @click="suspend">Suspend</CButton>
|
||||
<CButton variant="danger" @click="remove">Delete</CButton>`
|
||||
|
||||
const iconUsage = `<CButton icon="💾">Save</CButton>
|
||||
|
||||
<CButton icon="🗑️" variant="danger" aria-label="Delete record" />
|
||||
|
||||
<CButton>
|
||||
<template #icon><CIcon>📂</CIcon></template>
|
||||
Open
|
||||
</CButton>`
|
||||
|
||||
const stateUsage = `<CButton :disabled="!canSave">Save</CButton>
|
||||
<CButton :loading="isSaving" variant="primary">Save changes</CButton>`
|
||||
|
||||
const sizeUsage = `<CButton size="small">Small</CButton>
|
||||
<CButton size="medium">Medium</CButton>
|
||||
<CButton size="large">Large</CButton>`
|
||||
|
||||
const formUsage = `<form @submit.prevent="submit">
|
||||
<CButton type="submit" variant="primary">Submit</CButton>
|
||||
<CButton type="reset">Reset</CButton>
|
||||
</form>`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="button-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="category">Controls</p>
|
||||
<h1>Button</h1>
|
||||
</div>
|
||||
<p>
|
||||
<code>CButton</code> provides compact actions with native button behavior, restrained
|
||||
semantic variants, icons, and asynchronous states.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CSeparator />
|
||||
|
||||
<section class="section">
|
||||
<h2>Variants</h2>
|
||||
<p>
|
||||
Use color only when it communicates meaning: primary for the main action, success for a
|
||||
positive workflow outcome, warning for a consequential action, and danger for a
|
||||
destructive operation.
|
||||
</p>
|
||||
<div class="preview-row">
|
||||
<CButton @click="clickCount++">Default</CButton>
|
||||
<CButton variant="primary" @click="clickCount++">Primary</CButton>
|
||||
<CButton variant="success" @click="clickCount++">Success</CButton>
|
||||
<CButton variant="warning" @click="clickCount++">Warning</CButton>
|
||||
<CButton variant="danger" @click="clickCount++">Danger</CButton>
|
||||
<span class="status" aria-live="polite">Clicks: {{ clickCount }}</span>
|
||||
</div>
|
||||
<CCodeBlock class="code-sample" :code="basicUsage" />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Icons</h2>
|
||||
<p>
|
||||
Pass a Unicode symbol or emoji through <code>icon</code>, or use the named
|
||||
<code>icon</code> slot for custom presentation. Icon-only buttons require an
|
||||
<code>aria-label</code>.
|
||||
</p>
|
||||
<div class="preview-row">
|
||||
<CButton icon="💾">Save</CButton>
|
||||
<CButton icon="📂">Open</CButton>
|
||||
<CButton icon="🗑️" variant="danger" aria-label="Delete record" />
|
||||
<CButton aria-label="Confirm">
|
||||
<template #icon><CIcon color="green">✓</CIcon></template>
|
||||
</CButton>
|
||||
</div>
|
||||
<CCodeBlock class="code-sample" :code="iconUsage" />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Disabled and loading</h2>
|
||||
<p>
|
||||
Disabled buttons cannot be activated. Loading also disables the button, adds
|
||||
<code>aria-busy="true"</code>, and replaces its icon with a rotating progress symbol.
|
||||
</p>
|
||||
<div class="preview-row">
|
||||
<CButton disabled>Unavailable</CButton>
|
||||
<CButton loading>Loading</CButton>
|
||||
<CButton loading variant="primary">Saving changes</CButton>
|
||||
</div>
|
||||
<CCodeBlock class="code-sample" :code="stateUsage" />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Sizes</h2>
|
||||
<p>
|
||||
Use semantic sizes to keep controls consistent. The compact <code>medium</code> size is the
|
||||
default; small and large are available for denser tool areas or more prominent actions.
|
||||
</p>
|
||||
<div class="preview-row">
|
||||
<CButton size="small" icon="✓">Small</CButton>
|
||||
<CButton size="medium" icon="✓">Medium</CButton>
|
||||
<CButton size="large" icon="✓">Large</CButton>
|
||||
</div>
|
||||
<CCodeBlock class="code-sample" :code="sizeUsage" />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Native form behavior</h2>
|
||||
<p>
|
||||
The default <code>type</code> is <code>button</code>, preventing accidental form submission.
|
||||
Set it explicitly to <code>submit</code> or <code>reset</code> when needed. Native attributes
|
||||
and event listeners are forwarded to the underlying button.
|
||||
</p>
|
||||
<CCodeBlock class="code-sample" :code="formUsage" />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Properties</h2>
|
||||
<dl class="property-list">
|
||||
<div>
|
||||
<dt><code>variant</code></dt>
|
||||
<dd>
|
||||
<code>default</code>, <code>primary</code>, <code>success</code>,
|
||||
<code>warning</code>, or <code>danger</code>.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>type</code></dt>
|
||||
<dd><code>button</code>, <code>submit</code>, or <code>reset</code>.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>size</code></dt>
|
||||
<dd>
|
||||
<code>small</code> (26px), <code>medium</code> (30px, default), or
|
||||
<code>large</code> (34px).
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>icon</code></dt>
|
||||
<dd>An optional Unicode symbol or emoji displayed before the label.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>disabled</code></dt>
|
||||
<dd>Disables native interaction and applies disabled styling.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>loading</code></dt>
|
||||
<dd>Disables interaction and displays an accessible progress state.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>aria-label</code></dt>
|
||||
<dd>Provides an accessible name, particularly for icon-only buttons.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.button-page {
|
||||
width: min(100%, 1100px);
|
||||
margin: 0 auto;
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
|
||||
align-items: end;
|
||||
padding-bottom: 20px;
|
||||
gap: 32px;
|
||||
|
||||
> p {
|
||||
margin-bottom: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.category {
|
||||
margin: 0 0 4px;
|
||||
color: var(--c-primary-color, #2f6fad);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 22px 0;
|
||||
|
||||
> p {
|
||||
margin-bottom: 10px;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-inline-start: auto;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-sample {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.property-list {
|
||||
margin: 0;
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
|
||||
+ div {
|
||||
border-top: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 600;
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
border-inline-end: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
dd {
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { CIcon, CSeparator } from '@/index'
|
||||
import CCodeBlock from '@/documentation/CCodeBlock.vue'
|
||||
|
||||
import { emojiCatalog, emojiCatalogVersion } from './emoji-catalog'
|
||||
import type { EmojiCatalogEntry } from './emoji-catalog'
|
||||
import { emojiSearchAliases } from './emoji-search-aliases'
|
||||
|
||||
const pageSize = 120
|
||||
const search = ref('')
|
||||
const selectedGroup = ref('All')
|
||||
const selectedSubgroup = ref('All')
|
||||
const currentPage = ref(1)
|
||||
const copyStatus = ref('')
|
||||
|
||||
const emojiUsage = `<CIcon>🚚</CIcon>
|
||||
<CIcon display="circle" size="20px">📦</CIcon>
|
||||
<CIcon :rotate="-0.25" label="Launching">🚀</CIcon>`
|
||||
|
||||
const unicodeGroups = Array.from(new Set(emojiCatalog.map((entry) => entry.group)))
|
||||
const groups = ['All', ...unicodeGroups]
|
||||
|
||||
const groupCounts = new Map<string, number>()
|
||||
groupCounts.set('All', emojiCatalog.length)
|
||||
|
||||
for (const entry of emojiCatalog) {
|
||||
groupCounts.set(entry.group, (groupCounts.get(entry.group) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const subgroups = computed(() => {
|
||||
if (selectedGroup.value === 'All') return ['All']
|
||||
|
||||
return [
|
||||
'All',
|
||||
...new Set(
|
||||
emojiCatalog
|
||||
.filter((entry) => entry.group === selectedGroup.value)
|
||||
.map((entry) => entry.subgroup),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase()
|
||||
|
||||
return emojiCatalog.filter((entry) => {
|
||||
if (selectedGroup.value !== 'All' && entry.group !== selectedGroup.value) return false
|
||||
if (selectedSubgroup.value !== 'All' && entry.subgroup !== selectedSubgroup.value) return false
|
||||
if (!query) return true
|
||||
|
||||
const aliases = emojiSearchAliases[entry.name] ?? []
|
||||
|
||||
return (
|
||||
entry.name.toLocaleLowerCase().includes(query) ||
|
||||
entry.group.toLocaleLowerCase().includes(query) ||
|
||||
entry.subgroup.toLocaleLowerCase().includes(query) ||
|
||||
aliases.some((alias) => alias.includes(query))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(filteredEntries.value.length / pageSize)))
|
||||
|
||||
const pageEntries = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return filteredEntries.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const groupedPageEntries = computed(() => {
|
||||
const sections: Array<{
|
||||
key: string
|
||||
group: string
|
||||
subgroup: string
|
||||
entries: EmojiCatalogEntry[]
|
||||
}> = []
|
||||
|
||||
for (const entry of pageEntries.value) {
|
||||
const key = `${entry.group}/${entry.subgroup}`
|
||||
let section = sections.at(-1)
|
||||
|
||||
if (section?.key !== key) {
|
||||
section = { key, group: entry.group, subgroup: entry.subgroup, entries: [] }
|
||||
sections.push(section)
|
||||
}
|
||||
|
||||
section.entries.push(entry)
|
||||
}
|
||||
|
||||
return sections
|
||||
})
|
||||
|
||||
function selectGroup(group: string) {
|
||||
selectedGroup.value = group
|
||||
selectedSubgroup.value = 'All'
|
||||
}
|
||||
|
||||
async function copyEmoji(entry: EmojiCatalogEntry) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(entry.emoji)
|
||||
copyStatus.value = `Copied ${entry.emoji} — ${entry.name}`
|
||||
} catch {
|
||||
copyStatus.value = 'Copy failed. Select the emoji text and copy it manually.'
|
||||
}
|
||||
}
|
||||
|
||||
watch([selectedGroup, selectedSubgroup, search], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch(search, (value) => {
|
||||
if (!value.trim()) return
|
||||
selectedGroup.value = 'All'
|
||||
selectedSubgroup.value = 'All'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="glyph-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="category">Visual</p>
|
||||
<h1>Emoji</h1>
|
||||
</div>
|
||||
<p>
|
||||
Unicode Emoji {{ emojiCatalogVersion }} contains {{ emojiCatalog.length.toLocaleString() }}
|
||||
emoji characters and sequences. Render them with <code>CIcon</code>; artwork and color
|
||||
depend on the operating system.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CSeparator />
|
||||
|
||||
<section class="section">
|
||||
<h2>Unicode catalog</h2>
|
||||
<p>
|
||||
Browse the official Unicode groups and subgroups, then copy an emoji directly into a
|
||||
<code>CIcon</code> default slot.
|
||||
</p>
|
||||
|
||||
<div class="category-grid" aria-label="Emoji categories">
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group"
|
||||
type="button"
|
||||
class="category-button"
|
||||
:class="{ 'is-active': selectedGroup === group }"
|
||||
:aria-pressed="selectedGroup === group"
|
||||
@click="selectGroup(group)"
|
||||
>
|
||||
<span>{{ group }}</span>
|
||||
<span class="count">{{ groupCounts.get(group) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input v-model="search" type="search" placeholder="Name, group, or subgroup" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Subgroup</span>
|
||||
<select v-model="selectedSubgroup" :disabled="selectedGroup === 'All'">
|
||||
<option v-for="subgroup in subgroups" :key="subgroup" :value="subgroup">
|
||||
{{ subgroup }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="result-count">
|
||||
{{ filteredEntries.length.toLocaleString() }} result<span v-if="filteredEntries.length !== 1">s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="groupedPageEntries.length" class="catalog">
|
||||
<section v-for="section in groupedPageEntries" :key="section.key" class="subgroup">
|
||||
<header class="subgroup-header">
|
||||
<strong>{{ section.subgroup }}</strong>
|
||||
<span>{{ section.group }}</span>
|
||||
</header>
|
||||
|
||||
<div class="grid is-wide">
|
||||
<button
|
||||
v-for="entry in section.entries"
|
||||
:key="`${entry.emoji}-${entry.name}`"
|
||||
type="button"
|
||||
class="card interactive"
|
||||
:title="`Copy ${entry.name} · Emoji ${entry.version}`"
|
||||
@click="copyEmoji(entry)"
|
||||
>
|
||||
<CIcon size="22px">{{ entry.emoji }}</CIcon>
|
||||
<span class="details">
|
||||
<span>{{ entry.name }}</span>
|
||||
<code>E{{ entry.version }}</code>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p v-else class="empty">No emoji match the current filters.</p>
|
||||
|
||||
<p class="copy-status" aria-live="polite">{{ copyStatus }}</p>
|
||||
|
||||
<nav v-if="pageCount > 1" class="pagination" aria-label="Emoji catalog pages">
|
||||
<button type="button" :disabled="currentPage === 1" @click="currentPage--">Previous</button>
|
||||
<span>Page {{ currentPage }} of {{ pageCount }}</span>
|
||||
<button type="button" :disabled="currentPage === pageCount" @click="currentPage++">Next</button>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Presentation</h2>
|
||||
<p>Paste the selected character directly into the component's default slot.</p>
|
||||
<CCodeBlock :code="emojiUsage" />
|
||||
<div class="example-row">
|
||||
<div class="example">
|
||||
<CIcon size="16px">🍇</CIcon>
|
||||
<code><CIcon>🍇</CIcon></code>
|
||||
</div>
|
||||
<div class="example">
|
||||
<CIcon display="circle" size="18px">📦</CIcon>
|
||||
<code>18px circle</code>
|
||||
</div>
|
||||
<div class="example">
|
||||
<CIcon size="24px" :rotate="-0.25" label="Launching">🚀</CIcon>
|
||||
<code>:rotate="-0.25"</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./glyph-demo.scss"></style>
|
||||
@ -0,0 +1,418 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CCodeBlock from '@/documentation/CCodeBlock.vue'
|
||||
import { CMenu, CSeparator, CSideBar } from '@/index'
|
||||
import type { CMenuEntry, CMenuSelectEvent } from '@/index'
|
||||
|
||||
const lastAction = ref('No item selected')
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
const navigation: CMenuEntry[] = [
|
||||
{ label: 'Dashboard', icon: '⌂', active: true },
|
||||
{
|
||||
label: 'Sales',
|
||||
icon: '∑',
|
||||
children: [
|
||||
{ label: 'Orders', icon: '▤' },
|
||||
{ label: 'Customers', icon: '👥' },
|
||||
{
|
||||
label: 'Reports',
|
||||
icon: '▦',
|
||||
children: [
|
||||
{ label: 'Daily Report', icon: '▤' },
|
||||
{ label: 'Monthly Report', icon: '▤' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Administration',
|
||||
icon: '⚙︎',
|
||||
children: [
|
||||
{ label: 'Users', icon: '👥' },
|
||||
{ label: 'System Settings', icon: '⚙︎' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const sidebarUsage = `<CSideBar
|
||||
v-model:collapsed="sidebarCollapsed"
|
||||
collapsible
|
||||
width="250px"
|
||||
aria-label="Application navigation"
|
||||
>
|
||||
<template #header>
|
||||
<strong>Company workspace</strong>
|
||||
</template>
|
||||
|
||||
<CMenu
|
||||
:items="navigation"
|
||||
orientation="vertical"
|
||||
submenu-mode="inline"
|
||||
/>
|
||||
|
||||
<template #footer>Administrator</template>
|
||||
</CSideBar>`
|
||||
|
||||
const collapsibleUsage = `<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const sidebarCollapsed = ref(false)
|
||||
<\/script>
|
||||
|
||||
<template>
|
||||
<CSideBar
|
||||
collapsible
|
||||
v-model:collapsed="sidebarCollapsed"
|
||||
collapsed-width="38px"
|
||||
aria-label="Application navigation"
|
||||
>
|
||||
<template #header>
|
||||
<strong>Company workspace</strong>
|
||||
</template>
|
||||
|
||||
<CMenu :items="navigation" submenu-mode="inline" />
|
||||
</CSideBar>
|
||||
</template>`
|
||||
|
||||
const placementUsage = `<div class="workspace">
|
||||
<CSideBar placement="start" width="180px" aria-label="Navigation">
|
||||
<template #header><strong>Navigation</strong></template>
|
||||
Main menu
|
||||
</CSideBar>
|
||||
|
||||
<main>Application content</main>
|
||||
|
||||
<CSideBar placement="end" width="180px" aria-label="Properties">
|
||||
<template #header><strong>Properties</strong></template>
|
||||
Selection details
|
||||
</CSideBar>
|
||||
</div>
|
||||
|
||||
<style scoped>
|
||||
.workspace {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace > main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>`
|
||||
|
||||
const sidebarJavaScript = `import { ref } from 'vue'
|
||||
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: '⌂',
|
||||
url: '/dashboard',
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
label: 'Sales',
|
||||
icon: '∑',
|
||||
children: [
|
||||
{ label: 'Orders', icon: '▤', url: '/sales/orders' },
|
||||
{ label: 'Customers', icon: '👥', url: '/sales/customers' },
|
||||
{
|
||||
label: 'Reports',
|
||||
icon: '▦',
|
||||
children: [
|
||||
{ label: 'Daily Report', icon: '▤', url: '/sales/reports/daily' },
|
||||
{ label: 'Monthly Report', icon: '▤', url: '/sales/reports/monthly' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Administration',
|
||||
icon: '⚙︎',
|
||||
children: [
|
||||
{ label: 'Users', icon: '👥', url: '/admin/users' },
|
||||
{ label: 'System Settings', icon: '⚙︎', url: '/admin/settings' },
|
||||
],
|
||||
},
|
||||
]`
|
||||
|
||||
function recordSelection(event: CMenuSelectEvent) {
|
||||
lastAction.value = event.item.label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="sidebar-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="category">Layout</p>
|
||||
<h1>Sidebar</h1>
|
||||
</div>
|
||||
<p>
|
||||
<code>CSideBar</code> provides header, scrollable content, and footer regions for compact
|
||||
desktop application navigation.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CSeparator />
|
||||
|
||||
<section class="example">
|
||||
<div class="description">
|
||||
<h2>Navigation sidebar</h2>
|
||||
<p>
|
||||
A vertical menu expands inline while the surrounding application content remains fixed.
|
||||
Multiple branches can remain expanded.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<CSideBar
|
||||
v-model:collapsed="sidebarCollapsed"
|
||||
collapsible
|
||||
width="250px"
|
||||
aria-label="Example sidebar"
|
||||
>
|
||||
<template #header>
|
||||
<strong>Company workspace</strong>
|
||||
</template>
|
||||
|
||||
<CMenu
|
||||
:items="navigation"
|
||||
orientation="vertical"
|
||||
submenu-mode="inline"
|
||||
aria-label="Example sidebar navigation"
|
||||
@select="recordSelection"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<span class="user">Administrator</span>
|
||||
</template>
|
||||
</CSideBar>
|
||||
|
||||
<div class="placeholder">
|
||||
<strong>Application content</strong>
|
||||
<span>Select or expand items in the sidebar.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status" aria-live="polite">Last selection: {{ lastAction }}</div>
|
||||
<CCodeBlock class="code-sample" :code="sidebarUsage" />
|
||||
<CCodeBlock class="code-sample" :code="sidebarJavaScript" language="javascript" />
|
||||
</section>
|
||||
|
||||
<section class="example">
|
||||
<div class="description">
|
||||
<h2>Collapsible sidebar</h2>
|
||||
<p>
|
||||
Add <code>collapsible</code> to show a boxed menu button before the header content. The
|
||||
sidebar slides between its expanded and collapsed widths and keeps its menu state,
|
||||
including expanded menu branches.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="property-list">
|
||||
<div>
|
||||
<dt><code>collapsible</code></dt>
|
||||
<dd>Enables the header toggle. Without it, the sidebar always remains expanded.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>collapsed</code></dt>
|
||||
<dd>
|
||||
Controls the collapsed state through <code>v-model:collapsed</code>. The model is
|
||||
optional; without it, <code>CSideBar</code> manages its own state.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><code>collapsed-width</code></dt>
|
||||
<dd>Sets the width of the collapsed toggle strip. The default is <code>38px</code>.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<CCodeBlock class="code-sample" :code="collapsibleUsage" />
|
||||
</section>
|
||||
|
||||
<section class="example">
|
||||
<div class="description">
|
||||
<h2>Placement</h2>
|
||||
<p>
|
||||
Use the default <code>placement="start"</code> for navigation before the main content and
|
||||
<code>placement="end"</code> for tools or details after it. These are logical directions:
|
||||
start is left and end is right in left-to-right layouts, with the meaning reversed in
|
||||
right-to-left layouts.
|
||||
</p>
|
||||
<p>
|
||||
Placement controls which inner edge receives the divider. The parent flex or grid layout
|
||||
and the sidebar's document order determine its actual position. For the usual
|
||||
side-by-side application shell, give the parent <code>display: flex</code> and allow the
|
||||
main content to grow with <code>flex: 1</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="placement-preview">
|
||||
<CSideBar placement="start" width="180px" aria-label="Example navigation sidebar">
|
||||
<template #header><strong>Navigation</strong></template>
|
||||
Main menu
|
||||
</CSideBar>
|
||||
|
||||
<main class="placement-content">Application content</main>
|
||||
|
||||
<CSideBar placement="end" width="180px" aria-label="Example properties sidebar">
|
||||
<template #header><strong>Properties</strong></template>
|
||||
Selection details
|
||||
</CSideBar>
|
||||
</div>
|
||||
|
||||
<CCodeBlock class="code-sample" :code="placementUsage" />
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sidebar-page {
|
||||
width: min(100%, 1100px);
|
||||
margin: 0 auto;
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
|
||||
align-items: end;
|
||||
padding-bottom: 20px;
|
||||
gap: 32px;
|
||||
|
||||
> p {
|
||||
margin-bottom: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.category {
|
||||
margin: 0 0 4px;
|
||||
color: var(--c-primary-color, #2f6fad);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.example {
|
||||
padding: 22px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 10px;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.user {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 6px 9px;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.code-sample {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.property-list {
|
||||
margin: 0;
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(0, 1fr);
|
||||
|
||||
+ div {
|
||||
border-top: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 600;
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
border-inline-end: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
dd {
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.placement-preview {
|
||||
display: flex;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.placement-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,8 @@
|
||||
export const emojiSearchAliases: Record<string, readonly string[]> = {
|
||||
'floppy disk': ['disk', 'save', 'storage'],
|
||||
wastebasket: ['trash', 'trash can', 'delete', 'bin', 'recycle bin'],
|
||||
'file folder': ['folder', 'directory', 'closed folder'],
|
||||
'open file folder': ['folder', 'directory', 'open folder'],
|
||||
'bust in silhouette': ['user', 'person', 'account', 'profile'],
|
||||
'busts in silhouette': ['users', 'people', 'group', 'team', 'accounts'],
|
||||
}
|
||||
@ -0,0 +1,359 @@
|
||||
.glyph-page {
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.7fr) minmax(320px, 1.3fr);
|
||||
align-items: end;
|
||||
padding-bottom: 20px;
|
||||
gap: 32px;
|
||||
|
||||
> p {
|
||||
margin-bottom: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.category {
|
||||
margin: 0 0 4px;
|
||||
color: var(--c-primary-color, #2f6fad);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 22px 0;
|
||||
|
||||
> p {
|
||||
margin-bottom: 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
> .code-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--c-border-color, #d5d9df);
|
||||
border-inline-start: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
&.is-wide {
|
||||
grid-template-columns: repeat(auto-fill, minmax(185px, 1fr));
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 7px 9px;
|
||||
gap: 9px;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border-right: 1px solid var(--c-border-color, #d5d9df);
|
||||
border-bottom: 1px solid var(--c-border-color, #d5d9df);
|
||||
|
||||
> code {
|
||||
overflow: hidden;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.interactive {
|
||||
min-height: 48px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--c-hover-color, #eef1f5);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--c-focus-color, #3578c6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.example-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.example {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 180px;
|
||||
min-height: 54px;
|
||||
padding: 8px 12px;
|
||||
gap: 12px;
|
||||
|
||||
+ .example {
|
||||
border-inline-start: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.dark {
|
||||
background: #343a43;
|
||||
|
||||
code {
|
||||
color: #e8ebef;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
> span {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 4px 7px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 1px solid var(--c-border-color, #bfc5ce);
|
||||
border-radius: var(--c-border-radius, 3px);
|
||||
|
||||
&:focus {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-color: var(--c-focus-color, #3578c6);
|
||||
outline: 2px solid var(--c-focus-color, #3578c6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-count {
|
||||
margin-inline-start: auto;
|
||||
padding: 0 3px 6px;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.catalog {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.catalog-section {
|
||||
+ .catalog-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--c-border-color, #d5d9df);
|
||||
border-inline-start: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.category-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 0;
|
||||
border-right: 1px solid var(--c-border-color, #d5d9df);
|
||||
border-bottom: 1px solid var(--c-border-color, #d5d9df);
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--c-hover-color, #eef1f5);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--c-focus-color, #3578c6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--c-selection-text-color, #173f70);
|
||||
background: var(--c-selection-color, #dceafa);
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.subgroup {
|
||||
+ .subgroup {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.subgroup-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 5px;
|
||||
gap: 8px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 28px;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
text-align: center;
|
||||
border: 1px solid var(--c-border-color, #d5d9df);
|
||||
}
|
||||
|
||||
.copy-status {
|
||||
min-height: 18px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
margin-top: 10px;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
color: var(--c-muted-text-color, #626a75);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
button {
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 4px 7px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface-color, #fff);
|
||||
border: 1px solid var(--c-border-color, #bfc5ce);
|
||||
border-radius: var(--c-border-radius, 3px);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--c-focus-color, #3578c6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--c-disabled-text-color, #8a9099);
|
||||
cursor: default;
|
||||
background: var(--c-subtle-surface-color, #f7f8fa);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import ComponentsLayout from '@/layouts/ComponentsLayout.vue'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||
import HomePage from '@/pages/_Home.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: DefaultLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: HomePage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/components',
|
||||
component: ComponentsLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: { name: 'app-bar-menu' },
|
||||
},
|
||||
{
|
||||
path: 'app-bar-menu',
|
||||
name: 'app-bar-menu',
|
||||
component: () => import('@/pages/components/AppBarMenu.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sidebar',
|
||||
name: 'sidebar',
|
||||
component: () => import('@/pages/components/Sidebar.vue'),
|
||||
},
|
||||
{
|
||||
path: 'button',
|
||||
name: 'button',
|
||||
component: () => import('@/pages/components/Button.vue'),
|
||||
},
|
||||
{
|
||||
path: 'icon',
|
||||
name: 'icon',
|
||||
component: () => import('@/pages/components/Icon.vue'),
|
||||
},
|
||||
{
|
||||
path: 'emoji',
|
||||
name: 'emoji',
|
||||
component: () => import('@/pages/components/Emoji.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@ -0,0 +1,99 @@
|
||||
:root {
|
||||
color: #20242a;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
background: #f4f6f8;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.demo-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.demo-layout__brand {
|
||||
padding: 0 8px 0 2px;
|
||||
color: #20242a;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-layout__workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.demo-layout__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.demo-layout__main--with-sidebar {
|
||||
padding: 22px 24px 40px;
|
||||
}
|
||||
|
||||
.demo-button {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 4px 10px;
|
||||
color: #20242a;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #bfc5ce;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.demo-button:hover {
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.demo-button--primary {
|
||||
color: #fff;
|
||||
background: #286aa6;
|
||||
border-color: #245f95;
|
||||
}
|
||||
|
||||
.demo-button--primary:hover {
|
||||
background: #245f95;
|
||||
}
|
||||
|
||||
.demo-button:focus-visible,
|
||||
.demo-layout__brand:focus-visible {
|
||||
outline: 2px solid #3578c6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
Loading…
Reference in New Issue