diff --git a/docs/components/icons.md b/docs/components/icons.md index b4aa7967..cc08c8ef 100644 --- a/docs/components/icons.md +++ b/docs/components/icons.md @@ -28,9 +28,9 @@ Use `:name:` inline anywhere β€” in headings, buttons, nav bars, or plain text. | Name | Renders as | |------|------------| -| `:home:` | 🏠 | -| `:user:` | πŸ‘€ | -| `:settings:` | βš™οΈ | +| `:home:` / `:house:` | 🏠 | +| `:user:` / `:person:` | πŸ‘€ | +| `:settings:` / `:gear:` | βš™οΈ | | `:search:` | πŸ” | | `:star:` | ⭐ | | `:heart:` | ❀️ | @@ -43,11 +43,11 @@ Use `:name:` inline anywhere β€” in headings, buttons, nav bars, or plain text. | `:download:` | ⬇️ | | `:upload:` | ⬆️ | | `:edit:` | ✏️ | -| `:delete:` | πŸ—‘οΈ | +| `:delete:` / `:trash:` | πŸ—‘οΈ | | `:plus:` | βž• | | `:minus:` | βž– | | `:check:` | βœ“ | -| `:close:` | βœ• | +| `:close:` / `:x:` | βœ• | | `:menu:` | ☰ | | `:more:` | β‹― | | `:info:` | ℹ️ | @@ -104,6 +104,24 @@ Use `:name:` inline anywhere β€” in headings, buttons, nav bars, or plain text. | `:cloud:` | ☁️ | | `:wifi:` | πŸ“Ά | +### Domain / climate + +| Name | Renders as | +|------|------------| +| `:tree:` | 🌳 | +| `:plant:` | 🌱 | +| `:sun:` | β˜€οΈ | +| `:thermometer:` / `:heat:` | 🌑️ | +| `:drop:` / `:water:` | πŸ’§ | +| `:rain:` | 🌧️ | +| `:wind:` | πŸ’¨ | +| `:map:` | πŸ—ΊοΈ | +| `:building:` | 🏒 | +| `:shop:` | πŸͺ | +| `:tools:` | πŸ› οΈ | +| `:megaphone:` | πŸ“’ | +| `:handshake:` | 🀝 | + ### Activities | Name | Renders as | diff --git a/docs/components/not-implemented-components.md b/docs/components/not-implemented-components.md index 81c8e856..2893f7d7 100644 --- a/docs/components/not-implemented-components.md +++ b/docs/components/not-implemented-components.md @@ -120,7 +120,7 @@ Same as `[Select___v]` + list β€” the `v` suffix and following list already expr Segmented control. Used for view-mode switches (list/grid), alignment, and filters. -A `::: row` with a class modifier β€” row already creates a flex button container, `.toggle-group` styles it as a segmented control: +**Implemented.** A `::: row` with a class modifier β€” row already creates a flex button container, `.toggle-group` styles it as a segmented control: ``` ::: row {.toggle-group} @@ -280,14 +280,11 @@ New container using `###` items β€” same boundary convention as `::: grid-N`: Mobile tab bar fixed to the bottom. Used in mobile-first apps. -New container type β€” button links with icons follow the existing `[[:icon: Label]]` pattern: +**Implemented.** Nest a nav row inside `::: bottom-nav`. Trailing `*` on an item marks it active: ``` ::: bottom-nav -[[:home: Home]*] -[[:search: Explore]] -[[:bell: Alerts]] -[[:user: Profile]] +[[ :home: Feed* | :search: Explore | :bell: Alerts | :user: Profile ]] ::: ``` @@ -332,7 +329,7 @@ Extends image syntax β€” `![alt](src)` already works; avatar is an image with a Compact, optionally removable label. Distinct from badge β€” chips are interactive and can be dismissed. Used for tags, filters, selections. -Extends badge `|...|` syntax β€” static chip is a badge with `.chip` class; removable adds `Γ—`: +**Implemented.** Extends badge `|...|` syntax β€” static chip is a badge with `.chip` class; removable adds `Γ—`: ``` |React|{.chip} diff --git a/figma-plugin/src/lib/content-components.ts b/figma-plugin/src/lib/content-components.ts index 2fefaa96..3f897e77 100644 --- a/figma-plugin/src/lib/content-components.ts +++ b/figma-plugin/src/lib/content-components.ts @@ -147,16 +147,50 @@ export async function createIcon( // Icon mapping - using Unicode symbols and emoji const iconMap: Record = { - 'home': '🏠', 'user': 'πŸ‘€', 'settings': 'βš™οΈ', 'search': 'πŸ”', - 'star': '⭐', 'heart': '❀️', 'mail': 'βœ‰οΈ', 'phone': 'πŸ“ž', - 'calendar': 'πŸ“…', 'clock': 'πŸ•', 'location': 'πŸ“', 'link': 'πŸ”—', - 'download': '⬇️', 'upload': '⬆️', 'edit': '✏️', 'delete': 'πŸ—‘οΈ', - 'plus': 'βž•', 'minus': 'βž–', 'check': 'βœ“', 'close': 'βœ•', - 'menu': '☰', 'more': 'β‹―', 'info': 'ℹ️', 'warning': '⚠️', - 'error': '❌', 'success': 'βœ…', - 'arrow-up': '↑', 'arrow-down': '↓', 'arrow-left': '←', 'arrow-right': 'β†’', - 'twitter': '𝕏', 'github': 'βŠ™', 'linkedin': 'in', - 'default': '●' + home: '🏠', + user: 'πŸ‘€', + settings: 'βš™οΈ', + search: 'πŸ”', + star: '⭐', + heart: '❀️', + mail: 'βœ‰οΈ', + phone: 'πŸ“ž', + calendar: 'πŸ“…', + clock: 'πŸ•', + location: 'πŸ“', + link: 'πŸ”—', + download: '⬇️', + upload: '⬆️', + edit: '✏️', + delete: 'πŸ—‘οΈ', + plus: 'βž•', + minus: 'βž–', + check: 'βœ“', + close: 'βœ•', + menu: '☰', + more: 'β‹―', + info: 'ℹ️', + warning: '⚠️', + error: '❌', + success: 'βœ…', + 'arrow-up': '↑', + 'arrow-down': '↓', + 'arrow-left': '←', + 'arrow-right': 'β†’', + twitter: '𝕏', + github: 'βŠ™', + linkedin: 'in', + // Aliases + domain + house: '🏠', + person: 'πŸ‘€', + gear: 'βš™οΈ', + trash: 'πŸ—‘οΈ', + x: 'βœ•', + tree: '🌳', + map: 'πŸ—ΊοΈ', + thermometer: '🌑️', + heat: '🌑️', + default: '●', }; const iconName = node.props.name || 'default'; diff --git a/package.json b/package.json index d17150a4..e8528ca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wiremd", - "version": "0.1.8", + "version": "0.1.9", "description": "Text-first UI design tool - Create wireframes and mockups using Markdown syntax", "type": "module", "sideEffects": false, diff --git a/src/parser/index.ts b/src/parser/index.ts index 9f7567ed..7bd84e48 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -139,7 +139,7 @@ export function validate(ast: DocumentNode): ValidationError[] { code: 'MISSING_CONTAINER_TYPE', }); } else { - const validContainerTypes = ['hero', 'card', 'modal', 'sidebar', 'footer', 'alert', 'grid', 'layout', 'section', 'form-group', 'button-group']; + const validContainerTypes = ['hero', 'card', 'modal', 'sidebar', 'footer', 'alert', 'grid', 'layout', 'section', 'form-group', 'button-group', 'bottom-nav']; if (!validContainerTypes.includes(node.containerType)) { errors.push({ message: `Invalid containerType: "${node.containerType}". Must be one of: ${validContainerTypes.join(', ')}`, diff --git a/src/parser/transformer.ts b/src/parser/transformer.ts index b4c4e298..9140f8b9 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -437,11 +437,16 @@ function transformInlineContainer(node: any, _options: ParseOptions): WiremdNode continue; } - // Check if it starts with icon: :icon: Text - const iconTextMatch = trimmed.match(/^:([a-z-]+):\s*(.+)$/); + // Check if it starts with icon: :icon: Text (trailing * marks active) + const iconTextMatch = trimmed.match(/^:([a-z-]+):\s*(.+?)(\*)?$/); if (iconTextMatch) { const iconName = iconTextMatch[1]; - const text = iconTextMatch[2]; + let text = iconTextMatch[2].trim(); + const isActive = Boolean(iconTextMatch[3]) || /^\*\*?(.+?)\*\*?$/.test(text); + if (isActive) { + const inner = text.match(/^\*\*?(.+?)\*\*?$/); + if (inner) text = inner[1]; + } // Create a brand node for :logo:, otherwise nav-item const nodeType = iconName === 'logo' ? 'brand' : 'nav-item'; @@ -452,7 +457,7 @@ function transformInlineContainer(node: any, _options: ParseOptions): WiremdNode { type: 'icon', props: { name: iconName } }, { type: 'text', content: text }, ], - props: {}, + props: isActive && nodeType === 'nav-item' ? { classes: ['active'] } : {}, }); continue; } diff --git a/src/renderer/html-renderer.ts b/src/renderer/html-renderer.ts index fd8df0bc..1b4d57c3 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -8,6 +8,7 @@ */ import type { WiremdNode } from '../types.js'; +import { resolveIcon } from './icon-map.js'; export interface RenderContext { style: string; @@ -152,7 +153,15 @@ function renderButton(node: any, context: RenderContext): string { function renderBadge(node: any, context: RenderContext): string { const { classPrefix: prefix } = context; const classes = buildClasses(prefix, 'badge', node.props); - return `${escapeHtml(node.content)}`; + const raw = String(node.content ?? ''); + const isChip = (node.props?.classes || []).includes('chip'); + // Removable chip: "|Label Γ—|{.chip}" β€” keep the Γ— as a dismiss affordance. + const removable = isChip && /[Γ—x]\s*$/u.test(raw); + if (removable) { + const label = raw.replace(/\s*[Γ—x]\s*$/u, '').trim(); + return `${escapeHtml(label)}`; + } + return `${escapeHtml(raw)}`; } function renderInput(node: any, context: RenderContext): string { @@ -289,100 +298,7 @@ function renderIcon(node: any, context: RenderContext): string { const { classPrefix: prefix } = context; const classes = buildClasses(prefix, 'icon', node.props); const iconName = node.props.name || 'default'; - - // Icon mapping - using Unicode symbols and emoji - const iconMap: Record = { - // Social media - 'twitter': '𝕏', // Twitter/X logo approximation - 'github': 'βŠ™', // GitHub-like symbol - 'linkedin': 'in', // LinkedIn text representation - 'facebook': 'f', - 'instagram': 'β—‰', - 'youtube': 'β–Ά', - - // Common UI icons - 'home': '🏠', - 'user': 'πŸ‘€', - 'settings': 'βš™οΈ', - 'search': 'πŸ”', - 'star': '⭐', - 'heart': '❀️', - 'mail': 'βœ‰οΈ', - 'phone': 'πŸ“ž', - 'calendar': 'πŸ“…', - 'clock': 'πŸ•', - 'location': 'πŸ“', - 'link': 'πŸ”—', - 'download': '⬇️', - 'upload': '⬆️', - 'edit': '✏️', - 'delete': 'πŸ—‘οΈ', - 'plus': 'βž•', - 'minus': 'βž–', - 'check': 'βœ“', - 'close': 'βœ•', - 'menu': '☰', - 'more': 'β‹―', - 'info': 'ℹ️', - 'warning': '⚠️', - 'error': '❌', - 'success': 'βœ…', - - // Arrows - 'arrow-up': '↑', - 'arrow-down': '↓', - 'arrow-left': '←', - 'arrow-right': 'β†’', - - // Business/Finance - 'chart': 'πŸ“Š', - 'dollar': '$', - 'euro': '€', - 'pound': 'Β£', - - // Tech - 'code': '', - 'database': 'πŸ—„οΈ', - 'cloud': '☁️', - 'wifi': 'πŸ“Ά', - - // Communication - 'chat': 'πŸ’¬', - 'video': 'πŸŽ₯', - 'microphone': '🎀', - 'bell': 'πŸ””', - - // Files - 'file': 'πŸ“„', - 'folder': 'πŸ“', - 'image': 'πŸ–ΌοΈ', - 'document': 'πŸ“ƒ', - 'pdf': 'πŸ“‘', - - // Brand placeholders - 'logo': 'β—ˆ', - 'brand': 'β—†', - - // Activities - 'rocket': 'πŸš€', - 'bulb': 'πŸ’‘', - 'shield': 'πŸ›‘οΈ', - 'lock': 'πŸ”’', - 'unlock': 'πŸ”“', - 'key': 'πŸ”‘', - 'gift': '🎁', - 'trophy': 'πŸ†', - 'flag': '🚩', - 'bookmark': 'πŸ”–', - 'tag': '🏷️', - 'cart': 'πŸ›’', - 'credit-card': 'πŸ’³', - - // Default - 'default': '●' - }; - - const iconContent = iconMap[iconName] || iconMap['default']; + const iconContent = resolveIcon(iconName); // For social media icons, wrap in a styled span to make them look more icon-like const socialIcons = ['twitter', 'github', 'linkedin', 'facebook', 'instagram', 'youtube']; @@ -402,6 +318,10 @@ function renderContainer(node: any, context: RenderContext): string { return renderSidebarMainLayout(node, context, classes); } + if (node.containerType === 'bottom-nav') { + return renderBottomNav(node, context, classes); + } + const childrenHTML = (node.children || []).map((child: any) => renderNode(child, context)).join('\n '); return `
@@ -409,6 +329,55 @@ function renderContainer(node: any, context: RenderContext): string {
`; } +/** + * Mobile tab bar. Prefer a nested `[[ :icon: Label | ... ]]` nav; otherwise + * render button / nav-item children directly. + */ +function renderBottomNav(node: any, context: RenderContext, classes: string): string { + const { classPrefix: prefix } = context; + const kids: any[] = node.children || []; + let items: any[] = []; + if (kids.length === 1 && kids[0].type === 'nav') { + items = kids[0].children || []; + } else { + for (const child of kids) { + if (child.type === 'nav') { + items.push(...(child.children || [])); + } else if (child.type === 'nav-item' || child.type === 'button') { + items.push(child); + } else if (child.type === 'container' && child.containerType === 'button-group') { + items.push(...(child.children || [])); + } else if (child.type === 'container' && child.containerType === 'section') { + for (const nested of child.children || []) { + if (nested.type === 'nav-item' || nested.type === 'button') items.push(nested); + else if (nested.type === 'nav') items.push(...(nested.children || [])); + } + } + } + } + + const itemsHTML = items + .map((item: any) => { + if (item.type === 'nav-item') return renderNavItem(item, context); + if (item.type === 'button') { + const btn = renderButton(item, context); + // Promote primary button to active tab affordance in the bar. + if ((item.props?.variant === 'primary') && !btn.includes(`${prefix}active`)) { + return btn.replace(`class="`, `class="${prefix}active `); + } + return btn; + } + return renderNode(item, context); + }) + .join('\n '); + + return ``; +} + function renderSidebarMainLayout(node: any, context: RenderContext, classes: string): string { const { classPrefix: prefix } = context; const children: any[] = node.children || []; diff --git a/src/renderer/icon-map.ts b/src/renderer/icon-map.ts new file mode 100644 index 00000000..4c3b10c3 --- /dev/null +++ b/src/renderer/icon-map.ts @@ -0,0 +1,138 @@ +/** + * Shared monochrome icon-glyph map for `:name:` icon shortcodes. + * + * No color emoji: icons render either as plain (non-color-presentation) + * Unicode symbols, or as an empty glyph so only the label text shows. + * Aliases keep docs/skills names working (gear/trash/x) and add + * domain-friendly names (tree, house, person, map, …). + */ + +export const ICON_MAP: Record = { + // Social media + twitter: '𝕏', + github: 'βŠ™', + linkedin: 'in', + facebook: 'f', + instagram: 'β—‰', + youtube: 'β–Ά', + + // Common UI icons + home: '', + user: '', + settings: 'βš™', + search: '', + star: 'β˜†', + heart: 'β™₯', + mail: 'βœ‰', + phone: '', + calendar: '', + clock: '', + location: '', + link: '', + download: '⬇', + upload: '⬆', + edit: '✏', + delete: '', + plus: '+', + minus: 'βˆ’', + check: 'βœ“', + close: 'βœ•', + menu: '☰', + more: 'β‹―', + info: 'β„Ή', + warning: '⚠', + error: 'βœ•', + success: 'βœ“', + + // Arrows + 'arrow-up': '↑', + 'arrow-down': '↓', + 'arrow-left': '←', + 'arrow-right': 'β†’', + + // Business/Finance + chart: '', + dollar: '$', + euro: '€', + pound: 'Β£', + + // Tech + code: '', + database: '', + cloud: '☁', + wifi: '', + + // Communication + chat: '', + video: '', + microphone: '', + bell: '', + + // Files + file: '', + folder: '', + image: '', + document: '', + pdf: '', + + // Brand placeholders + logo: 'β—ˆ', + brand: 'β—†', + + // Activities + rocket: '', + bulb: '', + shield: '', + lock: '', + unlock: '', + key: '', + gift: '', + trophy: '', + flag: 'βš‘', + bookmark: '', + tag: '', + cart: '', + 'credit-card': '', + + // Domain / climate-adaptation friendly + tree: '', + plant: '', + leaf: '', + sun: 'β˜€', + thermometer: '', + drop: '', + rain: '', + wind: '', + map: '', + building: '', + shop: '', + tools: '', + clipboard: '', + megaphone: '', + handshake: '', + pin: '', + + // Aliases (skills docs + natural synonyms) + house: '', + person: '', + people: '', + users: '', + gear: 'βš™', + cog: 'βš™', + trash: '', + x: 'βœ•', + cross: 'βœ•', + 'pin-drop': '', + marker: '', + thermo: '', + heat: '', + water: '', + + // Default + default: '●', +}; + +export function resolveIcon(name: string | undefined | null): string { + const key = (name || 'default').toLowerCase(); + return ICON_MAP[key] ?? ICON_MAP.default; +} diff --git a/src/renderer/react-renderer.ts b/src/renderer/react-renderer.ts index d9710431..f1dc0bcf 100644 --- a/src/renderer/react-renderer.ts +++ b/src/renderer/react-renderer.ts @@ -8,6 +8,7 @@ */ import type { WiremdNode } from '../types.js'; +import { resolveIcon } from './icon-map.js'; export interface ReactRenderContext { classPrefix: string; @@ -281,14 +282,7 @@ function renderIcon(node: any, context: ReactRenderContext, indent: number): str const classAttr = context.useClassName ? 'className' : 'class'; // Icon mapping - const iconMap: Record = { - 'home': '🏠', 'user': 'πŸ‘€', 'settings': 'βš™οΈ', 'search': 'πŸ”', - 'star': '⭐', 'heart': '❀️', 'mail': 'βœ‰οΈ', 'phone': 'πŸ“ž', - 'check': 'βœ“', 'close': 'βœ•', 'menu': '☰', 'more': 'β‹―', - 'default': '●' - }; - - const iconContent = iconMap[iconName] || iconMap['default']; + const iconContent = resolveIcon(iconName); return `${indentStr}${iconContent}`; } diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index b1e975df..f0cb4431 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -37,6 +37,95 @@ export function getStyleCSS(style: string, prefix: string): string { .${prefix}container-button-group > .${prefix}select, .${prefix}row > .${prefix}grid-item > .${prefix}input, .${prefix}row > .${prefix}grid-item > .${prefix}select { display: inline-block; width: auto; } +.${prefix}row.${prefix}toggle-group { gap: 0; flex-wrap: nowrap; } +.${prefix}row.${prefix}toggle-group > .${prefix}grid-item { flex: 1 1 0; } +.${prefix}row.${prefix}toggle-group .${prefix}container-button-group { display: flex; width: 100%; gap: 0; } +.${prefix}row.${prefix}toggle-group .${prefix}button { + flex: 1 1 0; + border-radius: 0; + margin: 0; + justify-content: center; +} +.${prefix}row.${prefix}toggle-group .${prefix}button:first-child { border-radius: 9999px 0 0 9999px; } +.${prefix}row.${prefix}toggle-group .${prefix}button:last-child { border-radius: 0 9999px 9999px 0; } +.${prefix}row.${prefix}toggle-group .${prefix}grid-item:first-child .${prefix}button:first-child { border-radius: 9999px 0 0 9999px; } +.${prefix}row.${prefix}toggle-group .${prefix}grid-item:last-child .${prefix}button:last-child { border-radius: 0 9999px 9999px 0; } +`; + + // Bottom nav + chips β€” shared structure; themes can override colours + const bottomNavAndChipStructural = ` +.${prefix}container-bottom-nav { + position: sticky; + bottom: 0; + left: 0; + right: 0; + display: block; + margin: 1.5rem -1.5rem -1.5rem; + padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom, 0)); + border-top: 1px solid #e0e0e0; + background: #fff; + z-index: 20; +} +.${prefix}bottom-nav-content { + display: flex; + align-items: stretch; + justify-content: space-around; + gap: 0.25rem; +} +.${prefix}container-bottom-nav .${prefix}nav-item, +.${prefix}container-bottom-nav .${prefix}button { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.15rem; + min-height: 3.25rem; + padding: 0.35rem 0.25rem; + border: none; + background: transparent; + border-radius: 0.75rem; + font-size: 0.7rem; + font-weight: 500; + color: #666; + text-decoration: none; +} +.${prefix}container-bottom-nav .${prefix}nav-item.${prefix}active, +.${prefix}container-bottom-nav .${prefix}button.${prefix}active, +.${prefix}container-bottom-nav .${prefix}button-primary { + color: #111; + font-weight: 700; + background: #f3f3f3; +} +.${prefix}container-bottom-nav .${prefix}icon { font-size: 1.25rem; line-height: 1; } +.${prefix}badge.${prefix}chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.65rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + border-width: 1px; +} +.${prefix}badge.${prefix}chip-removable { padding-right: 0.35rem; } +.${prefix}chip-dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.1rem; + margin-left: 0.15rem; + border: none; + border-radius: 9999px; + background: transparent; + color: inherit; + font-size: 0.85rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} +.${prefix}chip-dismiss:hover { opacity: 1; background: rgba(0,0,0,0.08); } `; const demoStructural = ` @@ -61,7 +150,7 @@ export function getStyleCSS(style: string, prefix: string): string { case 'brutal': themeCSS = getBrutalStyle(prefix); break; default: themeCSS = getSketchStyle(prefix); } - return linkButtonReset + tabsStructural + rowStructural + demoStructural + themeCSS; + return linkButtonReset + tabsStructural + rowStructural + bottomNavAndChipStructural + demoStructural + themeCSS; } /** diff --git a/src/renderer/tailwind-renderer.ts b/src/renderer/tailwind-renderer.ts index 2f4e143b..558ce723 100644 --- a/src/renderer/tailwind-renderer.ts +++ b/src/renderer/tailwind-renderer.ts @@ -8,6 +8,7 @@ */ import type { WiremdNode } from '../types.js'; +import { resolveIcon } from './icon-map.js'; export interface TailwindRenderContext { pretty: boolean; @@ -266,14 +267,7 @@ function renderIcon(node: any, _context: TailwindRenderContext): string { const classes = 'inline-block align-middle'; const iconName = node.props.name || 'default'; - const iconMap: Record = { - 'home': '🏠', 'user': 'πŸ‘€', 'settings': 'βš™οΈ', 'search': 'πŸ”', - 'star': '⭐', 'heart': '❀️', 'mail': 'βœ‰οΈ', 'phone': 'πŸ“ž', - 'check': 'βœ“', 'close': 'βœ•', 'menu': '☰', 'more': 'β‹―', - 'default': '●' - }; - - const iconContent = iconMap[iconName] || iconMap['default']; + const iconContent = resolveIcon(iconName); return `${iconContent}`; } diff --git a/src/types.ts b/src/types.ts index e509bc7f..489c10e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,7 +54,7 @@ export interface DocumentNode { export type WiremdNode = // Layout - | { type: 'container'; containerType: 'hero' | 'card' | 'modal' | 'sidebar' | 'footer' | 'alert' | 'grid' | 'layout' | 'section' | 'form-group' | 'button-group'; props: ComponentProps; children: WiremdNode[]; position?: Location } + | { type: 'container'; containerType: 'hero' | 'card' | 'modal' | 'sidebar' | 'footer' | 'alert' | 'grid' | 'layout' | 'section' | 'form-group' | 'button-group' | 'bottom-nav'; props: ComponentProps; children: WiremdNode[]; position?: Location } | { type: 'nav'; props: ComponentProps; children: WiremdNode[]; position?: Location } | { type: 'nav-item'; content?: string; children?: WiremdNode[]; href?: string; props: ComponentProps; position?: Location } | { type: 'brand'; children: WiremdNode[]; props: ComponentProps; position?: Location } diff --git a/tests/bottom-nav-chips.test.ts b/tests/bottom-nav-chips.test.ts new file mode 100644 index 00000000..a862ffe1 --- /dev/null +++ b/tests/bottom-nav-chips.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser/index.js'; +import { renderToHTML } from '../src/renderer/index.js'; + +describe('bottom-nav', () => { + it('parses nested nav items with icons and active marker', () => { + const ast = parse(`::: bottom-nav +[[ :home: Feed* | :search: Themen | :user: Profil ]] +::: +`); + const nav = ast.children[0] as any; + expect(nav.type).toBe('container'); + expect(nav.containerType).toBe('bottom-nav'); + expect(nav.children[0].type).toBe('nav'); + const items = nav.children[0].children; + expect(items).toHaveLength(3); + expect(items[0].props?.classes).toContain('active'); + expect(items[1].props?.classes || []).not.toContain('active'); + }); + + it('renders a sticky bottom nav with active item', () => { + const html = renderToHTML( + parse(`::: bottom-nav +[[ :home: Feed* | :search: Themen | :user: Profil ]] +::: +`), + { style: 'clean' }, + ); + expect(html).toContain('wmd-container-bottom-nav'); + expect(html).toContain('aria-label="Bottom navigation"'); + expect(html).toContain('wmd-bottom-nav-content'); + expect(html).toContain('wmd-active'); + expect(html).toContain('Feed'); + expect(html).toContain('Themen'); + }); +}); + +describe('chips', () => { + it('parses static and removable chips', () => { + const ast = parse('|React|{.chip} |Design Γ—|{.chip}'); + const para = ast.children[0] as any; + expect(para.type).toBe('paragraph'); + const badges = (para.children || []).filter((c: any) => c.type === 'badge'); + expect(badges).toHaveLength(2); + expect(badges[0].content).toBe('React'); + expect(badges[0].props.classes).toContain('chip'); + expect(badges[1].content).toBe('Design Γ—'); + }); + + it('renders removable chip with dismiss control', () => { + const html = renderToHTML(parse('|Hitze Γ—|{.chip}'), { style: 'clean' }); + expect(html).toContain('wmd-chip'); + expect(html).toContain('wmd-chip-removable'); + expect(html).toContain('wmd-chip-dismiss'); + expect(html).toContain('Hitze'); + expect(html).toContain('aria-label="Remove Hitze"'); + }); +}); + +describe('toggle-group', () => { + it('applies toggle-group class on row', () => { + const ast = parse(`::: row {.toggle-group} +[List]* [Grid] [Map] +::: +`); + const row = ast.children[0] as any; + expect(row.type).toBe('row'); + expect(row.props.classes).toContain('toggle-group'); + }); + + it('emits segmented-control structural CSS', () => { + const html = renderToHTML( + parse(`::: row {.toggle-group} +[List]* [Grid] +::: +`), + { style: 'clean' }, + ); + expect(html).toContain('wmd-toggle-group'); + expect(html).toContain('.wmd-row.wmd-toggle-group'); + }); +}); diff --git a/tests/react-renderer.test.ts b/tests/react-renderer.test.ts index 42126fa8..90cbfc46 100644 --- a/tests/react-renderer.test.ts +++ b/tests/react-renderer.test.ts @@ -228,14 +228,14 @@ Email }); describe('Icons', () => { - it('should render icons', () => { + it('should render icon markers without color emoji', () => { const ast = parse('[[ :home: Home | :user: Profile ]]'); const jsx = renderToReact(ast); expect(jsx).toContain('data-icon="home"'); expect(jsx).toContain('data-icon="user"'); - expect(jsx).toContain('🏠'); - expect(jsx).toContain('πŸ‘€'); + expect(jsx).not.toContain('🏠'); + expect(jsx).not.toContain('πŸ‘€'); }); }); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index afc76d6e..b4e9c1f9 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -56,12 +56,36 @@ describe('HTML Renderer', () => { expect(html).toContain('Selected option'); }); - it('should render an icon', () => { - const ast = parse(':house:'); + it('should render an icon without color emoji', () => { + const ast = parse(':check:'); const html = renderToHTML(ast, { style: 'sketch' }); expect(html).toContain('wmd-icon'); - expect(html).toContain('data-icon="house"'); + expect(html).toContain('data-icon="check"'); + expect(html).toContain('βœ“'); + }); + + it('should resolve icon aliases to plain, non-color glyphs', () => { + const cases: Array<[string, string]> = [ + [':gear:', 'βš™'], + [':x:', 'βœ•'], + [':heart:', 'β™₯'], + [':star:', 'β˜†'], + ]; + for (const [src, glyph] of cases) { + const html = renderToHTML(parse(src), { style: 'sketch' }); + expect(html).toContain(glyph); + } + }); + + it('should render decorative-only icons (house/person/tree/map) with no glyph', () => { + const formerEmoji = ['🏠', 'πŸ‘€', 'πŸ—‘οΈ', '🌳', 'πŸ—ΊοΈ', '🌑️']; + for (const [i, name] of ['house', 'trash', 'person', 'tree', 'map', 'heat'].entries()) { + const html = renderToHTML(parse(`:${name}:`), { style: 'sketch' }); + expect(html).toContain(`data-icon="${name}"`); + expect(html).not.toContain(formerEmoji[i]); + expect(html).toContain(`aria-label="${name}">`); + } }); }); diff --git a/tests/tailwind-renderer.test.ts b/tests/tailwind-renderer.test.ts index e83ffef2..d9490ecd 100644 --- a/tests/tailwind-renderer.test.ts +++ b/tests/tailwind-renderer.test.ts @@ -281,14 +281,14 @@ Email }); describe('Icons', () => { - it('should render icons', () => { + it('should render icon markers without color emoji', () => { const ast = parse('[[ :home: Home | :user: Profile ]]'); const html = renderToTailwind(ast); expect(html).toContain('data-icon="home"'); expect(html).toContain('data-icon="user"'); - expect(html).toContain('🏠'); - expect(html).toContain('πŸ‘€'); + expect(html).not.toContain('🏠'); + expect(html).not.toContain('πŸ‘€'); }); });