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.
main
fandi.susanto.bts 1 month ago
parent 8c54d1f8db
commit 3495e9d544

@ -0,0 +1,196 @@
# AGENTS.md
# Concise UI Design Philosophy
Concise UI is a Vue component framework designed for **enterprise and productivity software**.
The goal is **not** to imitate Material Design, Bootstrap, mobile-first design, or flashy dashboards. The goal is to maximize usability, readability, and information density for desktop business applications.
Think:
- WinForms philosophy
- Modern web technology
- Professional enterprise appearance
---
# Core Principles
## Information Density
Every pixel should have a purpose.
Avoid excessive whitespace.
Interfaces should comfortably display many controls without feeling cramped.
Target use cases:
- ERP
- WMS
- CRM
- POS
- Finance
- Inventory
- Internal company systems
- Admin panels
Not:
- Marketing landing pages
- Portfolio websites
- Mobile-first applications
---
## Modern Appearance
Do NOT copy old WinForms visually.
Instead:
- clean
- flat
- subtle
- modern
Avoid:
- glossy gradients
- skeuomorphic effects
- excessive shadows
- oversized rounded corners
---
## Conservative Color Usage
Color communicates meaning.
Do not color everything.
Use color primarily for:
- Primary actions
- Success
- Warning
- Error
- Selection
- Focus
Most controls should remain neutral.
---
## Compact Components
Components should be intentionally compact.
Preferred defaults:
- Font: 1314px
- Control height: ~3032px
- Small paddings
- Tight spacing
- Small border radius
Avoid large mobile-oriented controls.
---
## Clear Visual Hierarchy
Hierarchy should come from:
- spacing
- typography
- borders
Not from giant cards or colorful backgrounds.
---
## Functional Borders
Borders are encouraged.
Inputs should always be visually distinguishable.
Panels may have subtle borders.
Tables should clearly separate rows and columns.
---
## Minimal Decoration
Avoid decoration unless it improves usability.
Examples:
Good
- subtle hover
- subtle focus ring
- subtle elevation
Avoid
- animations for decoration
- glowing effects
- floating cards everywhere
- unnecessary transitions
---
## Desktop First
Primary target:
Desktop users with keyboard and mouse.
Touch support is secondary.
Keyboard accessibility is first-class.
---
## Performance
Components should remain lightweight.
Avoid unnecessary dependencies.
Prefer native browser capabilities.
Tree-shaking should be supported.
---
## API Philosophy
APIs should be:
- predictable
- explicit
- consistent
Example:
```vue
<CButton />
<CInput />
<CCheckbox />
<CDialog />
<CDataTable />
```
Naming should remain consistent across the framework.
---
# Themes
Themes should modify only presentation.
Themes should NOT change:
- component behavior
- spacing philosophy
- sizing philosophy
Initial themes:
- Concise Light
- Concise Dark
Future themes may include:
- Classic
- High Contrast
---
# What Concise UI Is
- Efficient
- Compact
- Professional
- Enterprise-oriented
- Keyboard-friendly
- Productivity-focused
---
# What Concise UI Is NOT
- Material Design clone
- Bootstrap clone
- Tailwind component library
- Mobile-first framework
- Marketing website toolkit
- Highly animated UI library
---
# Decision Rule
When multiple implementations are possible, prefer the one that improves:
1. Productivity
2. Readability
3. Information density
4. Consistency
5. Simplicity
Even if it is slightly less visually trendy.

822
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -48,6 +48,7 @@
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^24.13.3",
"@types/prismjs": "^1.26.6",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/eslint-plugin": "^1.6.23",
"@vue/eslint-config-typescript": "^14.9.0",
@ -62,12 +63,15 @@
"npm-run-all2": "^9.0.2",
"oxlint": "^1.73.0",
"prettier": "3.9.5",
"prismjs": "^1.30.0",
"sass": "^1.102.0",
"typescript": "~6.0.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10",
"vue": "^3.5.40",
"vue-eslint-parser": "^10.4.1",
"vue-router": "^5.2.0",
"vue-tsc": "^3.3.7"
},
"peerDependencies": {

@ -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,3 @@
<script setup lang="ts"></script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<RouterView />
</template>
<style scoped></style>

@ -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,383 @@
<script setup lang="ts">
import { computed, nextTick } from 'vue'
import CSeparator from '../separator/CSeparator.vue'
import CIcon from '../icon/CIcon.vue'
import type {
CMenuActionItem,
CMenuEntry,
CMenuOrientation,
CMenuSelectEvent,
} from './types'
interface CMenuItemSlotProps {
item: CMenuActionItem
depth: number
expanded: boolean
}
const props = withDefaults(
defineProps<{
items: CMenuEntry[]
orientation: CMenuOrientation
submenuMode: 'inline' | 'flyout'
expandedKeys: Set<string>
level?: number
path?: string
parentItemKey?: string
ariaLabel?: string
}>(),
{
level: 0,
path: '',
parentItemKey: undefined,
ariaLabel: undefined,
},
)
const emit = defineEmits<{
toggle: [key: string, force?: boolean]
select: [event: CMenuSelectEvent]
}>()
defineSlots<{
item(props: CMenuItemSlotProps): unknown
}>()
const visibleItems = computed(() => props.items.filter((item) => !item.hidden))
const currentOrientation = computed<CMenuOrientation>(() =>
props.level === 0 ? props.orientation : 'vertical',
)
const isRoot = computed(() => props.level === 0)
function itemKey(item: CMenuEntry, index: number) {
const segment = item.id ? `id:${encodeURIComponent(item.id)}` : `index:${index}`
return props.path ? `${props.path}/${segment}` : segment
}
function isActionItem(item: CMenuEntry): item is CMenuActionItem {
return item.type !== 'separator'
}
function hasChildren(item: CMenuActionItem) {
return Boolean(item.children?.some((child) => !child.hidden))
}
function isFirstFocusable(index: number) {
return visibleItems.value.findIndex((item) => isActionItem(item) && !item.disabled) === index
}
function activateItem(event: MouseEvent, item: CMenuActionItem, key: string) {
if (item.disabled) {
event.preventDefault()
return
}
if (hasChildren(item)) {
event.preventDefault()
emit('toggle', key)
return
}
const context = { item, originalEvent: event }
item.command?.(context)
emit('select', context)
}
function directTriggers(menu: HTMLElement) {
return Array.from(menu.children)
.map((child) => child.querySelector<HTMLElement>(':scope > [data-c-menu-trigger]'))
.filter((trigger): trigger is HTMLElement => Boolean(trigger && trigger.getAttribute('aria-disabled') !== 'true'))
}
function focusRelative(menu: HTMLElement, target: HTMLElement, offset: number) {
const triggers = directTriggers(menu)
if (triggers.length === 0) return
const currentIndex = triggers.indexOf(target)
const nextIndex = (Math.max(currentIndex, 0) + offset + triggers.length) % triggers.length
triggers[nextIndex]?.focus()
}
async function openAndFocusSubmenu(target: HTMLElement, key: string) {
emit('toggle', key, true)
await nextTick()
const entry = target.closest('[data-c-menu-item]')
const submenu = entry?.querySelector<HTMLElement>(':scope > [data-c-menu-submenu]')
if (submenu) directTriggers(submenu)[0]?.focus()
}
function onKeydown(event: KeyboardEvent) {
const menu = event.currentTarget as HTMLElement
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-c-menu-trigger]')
if (!target || target.parentElement?.parentElement !== menu) return
const entry = target.closest<HTMLElement>('[data-c-menu-item]')
const key = entry?.dataset.itemKey
const ownsSubmenu = target.getAttribute('aria-haspopup') === 'menu'
const horizontal = currentOrientation.value === 'horizontal'
if ((horizontal && event.key === 'ArrowRight') || (!horizontal && event.key === 'ArrowDown')) {
event.preventDefault()
focusRelative(menu, target, 1)
} else if ((horizontal && event.key === 'ArrowLeft') || (!horizontal && event.key === 'ArrowUp')) {
event.preventDefault()
focusRelative(menu, target, -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
const triggers = directTriggers(menu)
const nextTarget = event.key === 'Home' ? triggers[0] : triggers.at(-1)
nextTarget?.focus()
} else if (
key &&
ownsSubmenu &&
((horizontal && event.key === 'ArrowDown') ||
(!horizontal && props.submenuMode === 'flyout' && event.key === 'ArrowRight'))
) {
event.preventDefault()
void openAndFocusSubmenu(target, key)
} else if (
key &&
ownsSubmenu &&
!horizontal &&
props.submenuMode === 'inline' &&
event.key === 'ArrowRight'
) {
event.preventDefault()
emit('toggle', key, true)
} else if (event.key === 'ArrowLeft' && !isRoot.value && props.parentItemKey) {
event.preventDefault()
const parentEntry = menu.parentElement?.closest<HTMLElement>('[data-c-menu-item]')
parentEntry?.querySelector<HTMLElement>(':scope > [data-c-menu-trigger]')?.focus()
emit('toggle', props.parentItemKey, false)
} else if (event.key === 'Escape' && !isRoot.value && props.parentItemKey) {
event.preventDefault()
const parentEntry = menu.parentElement?.closest<HTMLElement>('[data-c-menu-item]')
parentEntry?.querySelector<HTMLElement>(':scope > [data-c-menu-trigger]')?.focus()
emit('toggle', props.parentItemKey, false)
} else if (event.key === ' ' && target.tagName === 'A') {
event.preventDefault()
target.click()
}
}
</script>
<template>
<ul
class="c-menu"
:class="[
`is-${currentOrientation}`,
`is-${submenuMode}`,
{ 'is-root': isRoot, submenu: !isRoot },
]"
data-c-menu-list
:data-c-menu-submenu="isRoot ? undefined : ''"
:role="isRoot && orientation === 'horizontal' ? 'menubar' : 'menu'"
:aria-label="isRoot ? ariaLabel : undefined"
:aria-orientation="currentOrientation"
@keydown.stop="onKeydown"
>
<template v-for="(item, index) in visibleItems" :key="itemKey(item, index)">
<li v-if="item.type === 'separator'" class="separator" role="none">
<CSeparator
:orientation="isRoot && orientation === 'horizontal' ? 'vertical' : 'horizontal'"
/>
</li>
<li
v-else
class="item"
:class="{
'is-expanded': expandedKeys.has(itemKey(item, index)),
'is-disabled': item.disabled,
'is-active': item.active,
'has-children': hasChildren(item),
}"
role="none"
data-c-menu-item
:data-item-key="itemKey(item, index)"
>
<component
:is="item.url ? 'a' : 'button'"
class="trigger"
data-c-menu-trigger
:href="item.url"
:target="item.target"
:type="item.url ? undefined : 'button'"
role="menuitem"
:tabindex="item.disabled || !isFirstFocusable(index) ? -1 : 0"
:aria-disabled="item.disabled ? 'true' : undefined"
:aria-current="item.active ? 'page' : undefined"
:aria-haspopup="hasChildren(item) ? 'menu' : undefined"
:aria-expanded="hasChildren(item) ? expandedKeys.has(itemKey(item, index)) : undefined"
@click="activateItem($event, item, itemKey(item, index))"
>
<slot
name="item"
:item="item"
:depth="level"
:expanded="expandedKeys.has(itemKey(item, index))"
>
<CIcon v-if="item.icon" class="icon">{{ item.icon }}</CIcon>
<span class="label">{{ item.label }}</span>
<span v-if="hasChildren(item)" class="indicator" aria-hidden="true">
{{
submenuMode === 'inline'
? expandedKeys.has(itemKey(item, index))
? ''
: '+'
: isRoot && orientation === 'horizontal'
? '▾'
: ''
}}
</span>
</slot>
</component>
<CMenuList
v-if="hasChildren(item) && expandedKeys.has(itemKey(item, index))"
:items="item.children ?? []"
:orientation="orientation"
:submenu-mode="submenuMode"
:expanded-keys="expandedKeys"
:level="level + 1"
:path="itemKey(item, index)"
:parent-item-key="itemKey(item, index)"
@toggle="(key, force) => emit('toggle', key, force)"
@select="emit('select', $event)"
>
<template v-if="$slots.item" #item="slotProps">
<slot name="item" v-bind="slotProps" />
</template>
</CMenuList>
</li>
</template>
</ul>
</template>
<style scoped lang="scss">
.c-menu {
box-sizing: border-box;
display: flex;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
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);
&.is-horizontal {
flex-direction: row;
align-items: stretch;
}
&.is-vertical {
flex-direction: column;
width: 100%;
}
.item {
position: relative;
min-width: 0;
&.is-active > .trigger {
color: var(--c-selection-text-color, #173f70);
background: var(--c-selection-color, #dceafa);
}
&.is-disabled > .trigger {
color: var(--c-disabled-text-color, #8a9099);
pointer-events: none;
cursor: default;
}
}
.trigger {
box-sizing: border-box;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: var(--c-control-height, 30px);
margin: 0;
padding: 4px 8px;
gap: 6px;
overflow: hidden;
color: inherit;
font: inherit;
line-height: 1.2;
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: var(--c-border-radius, 3px);
&: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;
}
}
.icon,
.indicator {
flex: none;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
}
.indicator {
min-width: 1em;
color: var(--c-muted-text-color, #626a75);
text-align: center;
}
.separator {
display: flex;
align-items: stretch;
padding: 3px 6px;
}
&.is-horizontal > .separator {
padding: 5px 3px;
}
&.submenu {
z-index: var(--c-menu-z-index, 1000);
}
&.is-flyout.submenu {
position: absolute;
top: 0;
left: 100%;
min-width: var(--c-menu-min-width, 180px);
padding: 3px;
background: var(--c-surface-color, #fff);
border: 1px solid var(--c-border-color, #d5d9df);
border-radius: var(--c-border-radius, 3px);
box-shadow: var(--c-menu-shadow, 0 3px 10px rgb(20 28 38 / 14%));
}
&.is-horizontal.is-root > .item > .submenu {
top: 100%;
left: 0;
}
&.is-inline.submenu {
position: static;
padding-inline-start: 14px;
}
}
</style>

@ -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,168 @@
<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;
--c-surface-color: transparent;
--c-control-border-color: color-mix(in srgb, currentColor 35%, transparent);
--c-hover-color: color-mix(in srgb, currentColor 10%, transparent);
--c-active-color: color-mix(in srgb, currentColor 18%, transparent);
}
&.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 &amp; 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>&lt;CIcon&gt;🍇&lt;/CIcon&gt;</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,186 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CIcon, CSeparator } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
import { iconCatalog } from './icon-catalog'
import type { IconCatalogEntry } from './icon-catalog'
const selectedCategory = ref('All')
const search = ref('')
const copyStatus = ref('')
const iconUsage = `<CIcon>✓</CIcon>
<CIcon display="circle" size="20px"></CIcon>
<CIcon color="red"></CIcon>
<CIcon :rotate="-0.5" label="Refreshing"></CIcon>`
const categories = ['All', ...new Set(iconCatalog.map((entry) => entry.category))]
const filteredIcons = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return iconCatalog.filter((entry) => {
if (selectedCategory.value !== 'All' && entry.category !== selectedCategory.value) return false
if (!query) return true
return (
entry.name.toLocaleLowerCase().includes(query) ||
entry.category.toLocaleLowerCase().includes(query) ||
entry.icon.includes(query)
)
})
})
const groupedIcons = computed(() => {
const groups = new Map<string, IconCatalogEntry[]>()
for (const entry of filteredIcons.value) {
const entries = groups.get(entry.category) ?? []
entries.push(entry)
groups.set(entry.category, entries)
}
return Array.from(groups, ([category, entries]) => ({ category, entries }))
})
async function copyIcon(entry: IconCatalogEntry) {
try {
await navigator.clipboard.writeText(entry.icon)
copyStatus.value = `Copied ${entry.icon}${entry.name}`
} catch {
copyStatus.value = 'Copy failed. Select the symbol and copy it manually.'
}
}
</script>
<template>
<article class="glyph-page">
<header class="page-header">
<div>
<p class="category">Visual</p>
<h1>Icon</h1>
</div>
<p>
<code>CIcon</code> presents monochrome Unicode symbols using the surrounding text color.
Copy a curated symbol into the component's default slot.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Curated symbol catalog</h2>
<p>
These symbols were selected for compact application commands and indicators. Rendering
can vary slightly with the installed symbol font.
</p>
<div class="filters">
<label>
<span>Category</span>
<select v-model="selectedCategory">
<option v-for="category in categories" :key="category" :value="category">
{{ category }}
</option>
</select>
</label>
<label>
<span>Search</span>
<input v-model="search" type="search" placeholder="Name, category, or symbol" />
</label>
<span class="result-count">{{ filteredIcons.length }} symbols</span>
</div>
<div v-if="groupedIcons.length" class="catalog">
<section v-for="group in groupedIcons" :key="group.category" class="catalog-section">
<h3>{{ group.category }}</h3>
<div class="grid">
<button
v-for="entry in group.entries"
:key="`${entry.category}-${entry.name}`"
type="button"
class="card interactive"
:title="`Copy ${entry.name}`"
@click="copyIcon(entry)"
>
<CIcon size="20px">{{ entry.icon }}</CIcon>
<code>{{ entry.name }}</code>
</button>
</div>
</section>
</div>
<p v-else class="empty">No symbols match the current filters.</p>
<p class="copy-status" aria-live="polite">{{ copyStatus }}</p>
</section>
<section class="section">
<h2>Usage</h2>
<p>Paste a Unicode symbol into the default slot and configure its presentation with props.</p>
<CCodeBlock :code="iconUsage" />
</section>
<section class="section">
<h2>Display and size</h2>
<div class="example-row">
<div class="example">
<CIcon size="16px"></CIcon>
<code>&lt;CIcon&gt;&lt;/CIcon&gt;</code>
</div>
<div class="example">
<CIcon size="1.5em"></CIcon>
<code>1.5em inline</code>
</div>
<div class="example">
<CIcon display="circle" size="18px"></CIcon>
<code>18px circle</code>
</div>
</div>
</section>
<section class="section">
<h2>Color</h2>
<p>The color prop accepts any valid CSS color value and applies it to the symbol.</p>
<div class="example-row">
<div class="example">
<CIcon size="22px" color="red"></CIcon>
<code>red</code>
</div>
<div class="example">
<CIcon display="circle" size="18px" color="green"></CIcon>
<code>green</code>
</div>
<div class="example dark">
<CIcon size="22px" color="#FFFF00"></CIcon>
<code>#FFFF00</code>
</div>
<div class="example">
<CIcon size="22px" color="rgba(47, 111, 173, 0.65)"></CIcon>
<code>rgba(47, 111, 173, 0.65)</code>
</div>
</div>
</section>
<section class="section">
<h2>Rotation</h2>
<p>The signed value represents revolutions per second. Negative values rotate counterclockwise.</p>
<div class="example-row">
<div class="example">
<CIcon display="circle" size="20px" :rotate="1" label="Loading settings"></CIcon>
<code>:rotate="1"</code>
</div>
<div class="example">
<CIcon size="24px" :rotate="-0.5" label="Refreshing"></CIcon>
<code>:rotate="-0.5"</code>
</div>
</div>
</section>
</article>
</template>
<style scoped lang="scss" src="./glyph-demo.scss"></style>

@ -0,0 +1,492 @@
<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 backgroundUsage = `<CSideBar style="background: #dbe8f5">
Solid color
</CSideBar>
<CSideBar style="background: linear-gradient(180deg, #ffffff, #e7eef6)">
Gradient
</CSideBar>
<CSideBar class="image-sidebar">
Background image
</CSideBar>
<style scoped>
.image-sidebar {
--c-text-color: #fff;
background: center / cover no-repeat url('/images/sidebar.jpg');
}
</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>
<section class="example">
<div class="description">
<h2>Custom backgrounds</h2>
<p>
Apply standard <code>class</code> or <code>style</code> attributes to customize the
sidebar with any valid CSS background, including a color, gradient, or image.
</p>
<p>
For dark backgrounds, set <code>--c-text-color</code> so sidebar content and nested
Concise UI components use a suitable foreground color.
</p>
</div>
<div class="background-preview">
<CSideBar
class="solid-background"
width="200px"
aria-label="Solid background example"
>
<template #header><strong>Solid color</strong></template>
#dbe8f5
</CSideBar>
<CSideBar
class="gradient-background"
width="200px"
aria-label="Gradient background example"
>
<template #header><strong>Gradient</strong></template>
linear-gradient(...)
</CSideBar>
</div>
<CCodeBlock class="code-sample" :code="backgroundUsage" />
</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);
}
.background-preview {
display: flex;
height: 150px;
overflow: hidden;
gap: 12px;
.c-side-bar {
border: 1px solid var(--c-border-color, #d5d9df);
}
.solid-background {
background: #dbe8f5;
}
.gradient-background {
--c-text-color: #fff;
background: linear-gradient(180deg, #34495e, #1f2d3a);
}
}
}
</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,104 @@
export interface IconCatalogEntry {
icon: string
name: string
category: string
}
export const iconCatalog: IconCatalogEntry[] = [
{ icon: '+', name: 'add', category: 'Actions' },
{ icon: '', name: 'remove', category: 'Actions' },
{ icon: '×', name: 'close', category: 'Actions' },
{ icon: '✓', name: 'check', category: 'Actions' },
{ icon: '✎', name: 'edit', category: 'Actions' },
{ icon: '↻', name: 'refresh', category: 'Actions' },
{ icon: '↶', name: 'undo', category: 'Actions' },
{ icon: '↷', name: 'redo', category: 'Actions' },
{ icon: '⇧', name: 'upload', category: 'Actions' },
{ icon: '⇩', name: 'download', category: 'Actions' },
{ icon: '↩', name: 'return', category: 'Actions' },
{ icon: '↵', name: 'enter', category: 'Actions' },
{ icon: '⌂', name: 'home', category: 'Navigation' },
{ icon: '☰', name: 'menu', category: 'Navigation' },
{ icon: '↑', name: 'arrow up', category: 'Navigation' },
{ icon: '↓', name: 'arrow down', category: 'Navigation' },
{ icon: '←', name: 'arrow left', category: 'Navigation' },
{ icon: '→', name: 'arrow right', category: 'Navigation' },
{ icon: '', name: 'chevron left', category: 'Navigation' },
{ icon: '', name: 'chevron right', category: 'Navigation' },
{ icon: '⌃', name: 'chevron up', category: 'Navigation' },
{ icon: '⌄', name: 'chevron down', category: 'Navigation' },
{ icon: '«', name: 'first', category: 'Navigation' },
{ icon: '»', name: 'last', category: 'Navigation' },
{ icon: '⤢', name: 'expand', category: 'Navigation' },
{ icon: '⋯', name: 'more horizontal', category: 'Navigation' },
{ icon: '⋮', name: 'more vertical', category: 'Navigation' },
{ icon: 'ⓘ', name: 'information', category: 'Status' },
{ icon: '?', name: 'question', category: 'Status' },
{ icon: '⚠︎', name: 'warning', category: 'Status' },
{ icon: '⊗', name: 'error', category: 'Status' },
{ icon: '⊘', name: 'prohibited', category: 'Status' },
{ icon: '◉', name: 'selected', category: 'Status' },
{ icon: '○', name: 'unselected', category: 'Status' },
{ icon: '◷', name: 'pending', category: 'Status' },
{ icon: '☆', name: 'star outline', category: 'Status' },
{ icon: '★', name: 'star filled', category: 'Status' },
{ icon: '♡', name: 'heart outline', category: 'Status' },
{ icon: '♥', name: 'heart filled', category: 'Status' },
{ icon: '▶︎', name: 'play', category: 'Media' },
{ icon: 'Ⅱ', name: 'pause', category: 'Media' },
{ icon: '■', name: 'stop', category: 'Media' },
{ icon: '●', name: 'record', category: 'Media' },
{ icon: '◀︎', name: 'previous', category: 'Media' },
{ icon: '▶︎', name: 'next', category: 'Media' },
{ icon: '◀◀', name: 'rewind', category: 'Media' },
{ icon: '▶▶', name: 'fast forward', category: 'Media' },
{ icon: '⏏︎', name: 'eject', category: 'Media' },
{ icon: '♪', name: 'music', category: 'Media' },
{ icon: '□', name: 'window', category: 'Layout' },
{ icon: '❐', name: 'restore window', category: 'Layout' },
{ icon: '◧', name: 'panel left', category: 'Layout' },
{ icon: '◨', name: 'panel right', category: 'Layout' },
{ icon: '▥', name: 'columns', category: 'Layout' },
{ icon: '▦', name: 'grid', category: 'Layout' },
{ icon: '≡', name: 'list', category: 'Layout' },
{ icon: '⊢', name: 'tree branch', category: 'Layout' },
{ icon: '⊟', name: 'collapse', category: 'Layout' },
{ icon: '⊞', name: 'expand node', category: 'Layout' },
{ icon: '⇅', name: 'sort', category: 'Data' },
{ icon: '⇡', name: 'sort ascending', category: 'Data' },
{ icon: '⇣', name: 'sort descending', category: 'Data' },
{ icon: '∑', name: 'sum', category: 'Data' },
{ icon: '∞', name: 'infinity', category: 'Data' },
{ icon: '%', name: 'percent', category: 'Data' },
{ icon: '=', name: 'equal', category: 'Data' },
{ icon: '≠', name: 'not equal', category: 'Data' },
{ icon: '≥', name: 'greater or equal', category: 'Data' },
{ icon: '≤', name: 'less or equal', category: 'Data' },
{ icon: 'ƒ', name: 'function', category: 'Data' },
{ icon: '⚙︎', name: 'settings', category: 'System' },
{ icon: '⌕', name: 'search', category: 'System' },
{ icon: '⎙', name: 'print', category: 'System' },
{ icon: '⌨︎', name: 'keyboard', category: 'System' },
{ icon: '⌘', name: 'command', category: 'System' },
{ icon: '⌥', name: 'option', category: 'System' },
{ icon: '⏻', name: 'power', category: 'System' },
{ icon: '✉︎', name: 'mail', category: 'System' },
{ icon: '☎︎', name: 'phone', category: 'System' },
{ icon: '¶', name: 'paragraph', category: 'Text' },
{ icon: '§', name: 'section', category: 'Text' },
{ icon: '•', name: 'bullet', category: 'Text' },
{ icon: '…', name: 'ellipsis', category: 'Text' },
{ icon: '❝', name: 'quote', category: 'Text' },
{ icon: '⇥', name: 'indent', category: 'Text' },
{ icon: '⇤', name: 'outdent', category: 'Text' },
{ icon: '©', name: 'copyright', category: 'Text' },
{ icon: '®', name: 'registered', category: 'Text' },
{ icon: '™', name: 'trademark', category: 'Text' },
]

@ -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;
}

@ -1,7 +1,7 @@
{
"extends": "./tsconfig.app.json",
"include": ["env.d.ts", "src/index.ts", "src/**/*.vue"],
"exclude": ["src/__tests__/**", "src/App.vue", "src/main.ts"],
"include": ["env.d.ts", "src/index.ts", "src/components/**/*.ts", "src/components/**/*.vue"],
"exclude": ["src/__tests__/**"],
"compilerOptions": {
"noEmit": false,
"declaration": true,

@ -3,7 +3,7 @@ import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
await viteConfig({ command: 'serve', mode: 'test', isSsrBuild: false, isPreview: false }),
defineConfig({
test: {
environment: 'jsdom',

Loading…
Cancel
Save