fix(ui): repair sidebar drawer, dropdown focus and footer slots

This commit is contained in:
Clintchiz committed 2026-08-26 12:03:50 +05:30
1 parent f7fe3bb17b
commit 6acf78a72c
8 files changed
+378 -34

No files matched your search

+1 -1
View File
@@ -688,7 +688,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.8.56", "version": "0.8.57",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
+6 -6
View File
@@ -119,7 +119,7 @@ Theme-aware, responsive badge component.
Theme-aware, responsive blockquote component. Theme-aware, responsive blockquote component.
- Mount: `data-component="Blockquote"` - Mount: `data-component="Blockquote"`
- Props: `quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."`, `citation: string = ""`, `citationTitle: string = ""`, `citationUrl: string = ""`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `size: string = "md"`, `color: string = "primary"`, `align: string = "left"`, `variant: string = "default"`, `quoteMark: boolean = true`, `italic: boolean = true`, `class: string = ""` - Props: `quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."`, `citation: string = ""`, `citationTitle: string = ""`, `citationUrl: string = ""`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `size: string = "md"`, `color: string = "primary"`, `align: string = "left"`, `variant: string = "default"`, `quoteMark: boolean = true`, `italic: boolean = true`, `maxWidth: string = "xl"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: None
@@ -290,7 +290,7 @@ Theme-aware, responsive styled icon component.
Present responsive chronological activity, milestones, or workflow status with rich item metadata. Present responsive chronological activity, milestones, or workflow status with rich item metadata.
- Mount: `data-component="Timeline"` - Mount: `data-component="Timeline"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `numbered: boolean = false`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })` - Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
@@ -827,7 +827,7 @@ Theme-aware, responsive navbar component.
- Mount: `data-component="Navbar"` - Mount: `data-component="Navbar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: Record<string, unknown> = {}`, `items: unknown[] = []`, `actions: unknown[] = []`, `active: string = ""`, `sticky: boolean = false`, `floating: boolean = false`, `startFloating: string = "0px"`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `collapseAt: string = "900px"`, `mobilePanel: string = "content"`, `mobileUtilitiesPosition: string = "header"`, `gutter: string = "md"`, `contentGutter: string = "md"`, `flushMobile: boolean = false`, `translationPrefix: string = ""`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: Record<string, unknown> = {}`, `items: unknown[] = []`, `actions: unknown[] = []`, `active: string = ""`, `sticky: boolean = false`, `floating: boolean = false`, `startFloating: string = "0px"`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `collapseAt: string = "900px"`, `mobilePanel: string = "content"`, `mobileUtilitiesPosition: string = "header"`, `gutter: string = "md"`, `contentGutter: string = "md"`, `flushMobile: boolean = false`, `translationPrefix: string = ""`, `class: string = ""`
- Slots: `topbar`, `actions`, `utilities` - Slots: `topbar`, `brand`, `actions`, `utilities`
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })`, `action({ item: string | number | boolean | null | object; value: string | number | boolean })` - Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })`, `action({ item: string | number | boolean | null | object; value: string | number | boolean })`
### Pagination ### Pagination
@@ -853,7 +853,7 @@ Theme-aware, responsive scrollspy component.
Theme-aware, responsive sidebar component. Theme-aware, responsive sidebar component.
- Mount: `data-component="Sidebar"` - Mount: `data-component="Sidebar"`
- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `mobile: boolean = true`, `drawerPlacement: string = "left"`, `class: string = ""`
- Slots: `default`, `drawer` - Slots: `default`, `drawer`
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent: Event })`, `close({ source: string })`, `select({ item: object; value: string; level: number })` - Outputs: `toggle({ open: boolean })`, `open({ sourceEvent: Event })`, `close({ source: string })`, `select({ item: object; value: string; level: number })`
@@ -872,7 +872,7 @@ Switch between related responsive content panels with horizontal or vertical ori
- Mount: `data-component="Tabs"` - Mount: `data-component="Tabs"`
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mode: string = "client"`, `param: string = "tab"`, `label: string = "Tabs"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mode: string = "client"`, `param: string = "tab"`, `label: string = "Tabs"`, `class: string = ""`
- Slots: `panel-{valueOf(item, index)}`, `default` - Slots: `default`
- Outputs: `change({ value: string; item: object; index: number })`, `select({ value: string; item: object; index: number })` - Outputs: `change({ value: string; item: object; index: number })`, `select({ value: string; item: object; index: number })`
## Overlays ## Overlays
@@ -900,7 +900,7 @@ Present responsive modal side or bottom content with focus management, backdrop
Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement. Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement.
- Mount: `data-component="Dropdown"` - Mount: `data-component="Dropdown"`
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""` - Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `ariaLabel: string = ""`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer` - Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `toggle({ open: boolean; sourceEvent: Event; reason?: object })`, `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `select({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })` - Outputs: `toggle({ open: boolean; sourceEvent: Event; reason?: object })`, `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `select({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`
+44 -2
View File
@@ -1751,6 +1751,13 @@
"default": "true", "default": "true",
"options": [] "options": []
}, },
{
"name": "maxWidth",
"type": "string",
"required": false,
"default": "\"xl\"",
"options": []
},
{ {
"name": "class", "name": "class",
"type": "string", "type": "string",
@@ -1791,6 +1798,13 @@
"default": "\"\"", "default": "\"\"",
"options": [] "options": []
}, },
{
"name": "activeIcon",
"type": "string",
"required": false,
"default": "\"icon-[lucide--file-text]\"",
"options": []
},
{ {
"name": "separator", "name": "separator",
"type": "string", "type": "string",
@@ -5461,6 +5475,13 @@
"default": "\"Open menu\"", "default": "\"Open menu\"",
"options": [] "options": []
}, },
{
"name": "ariaLabel",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{ {
"name": "icon", "name": "icon",
"type": "string", "type": "string",
@@ -9282,7 +9303,7 @@
"options": [] "options": []
} }
], ],
"slots": ["topbar", "actions", "utilities"], "slots": ["topbar", "brand", "actions", "utilities"],
"outputs": [ "outputs": [
{ {
"name": "toggle", "name": "toggle",
@@ -11528,6 +11549,20 @@
"default": "\"Navigation\"", "default": "\"Navigation\"",
"options": [] "options": []
}, },
{
"name": "mobile",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "drawerPlacement",
"type": "string",
"required": false,
"default": "\"left\"",
"options": []
},
{ {
"name": "class", "name": "class",
"type": "string", "type": "string",
@@ -12727,7 +12762,7 @@
"options": [] "options": []
} }
], ],
"slots": ["panel-{valueOf(item, index)}", "default"], "slots": ["default"],
"outputs": [ "outputs": [
{ {
"name": "change", "name": "change",
@@ -13329,6 +13364,13 @@
"default": "\"default\"", "default": "\"default\"",
"options": [] "options": []
}, },
{
"name": "numbered",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{ {
"name": "class", "name": "class",
"type": "string", "type": "string",
+35 -13
View File
@@ -12,6 +12,7 @@ items: unknown[] = []
open: boolean = false open: boolean = false
defaultOpen: boolean = false defaultOpen: boolean = false
label: string = "Open menu" label: string = "Open menu"
ariaLabel: string = ""
icon: string = "" icon: string = ""
showChevron: boolean = true showChevron: boolean = true
menuLabel: string = "Dropdown menu" menuLabel: string = "Dropdown menu"
@@ -47,6 +48,11 @@ items: unknown[] = []
visible = false visible = false
output.close({ reason: reason, sourceEvent: sourceEvent }) output.close({ reason: reason, sourceEvent: sourceEvent })
output.toggle({ open: false, reason: reason, sourceEvent: sourceEvent }) output.toggle({ open: false, reason: reason, sourceEvent: sourceEvent })
if (reason === "escape") {
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const trigger = root.querySelector(".wrn-dropdown__trigger")
if (trigger) { trigger.focus() }
}
} }
client function toggleMenu(sourceEvent) { client function toggleMenu(sourceEvent) {
@@ -65,14 +71,14 @@ items: unknown[] = []
output.select({ output.select({
item: item, item: item,
itemIndex: itemIndex, itemIndex: itemIndex,
value: item.value || "", value: item.value == null ? "" : item.value,
sourceEvent: sourceEvent sourceEvent: sourceEvent
}) })
if (item.action) { if (item.action) {
output.action({ output.action({
item: item, item: item,
itemIndex: itemIndex, itemIndex: itemIndex,
value: item.value || "", value: item.value == null ? "" : item.value,
sourceEvent: sourceEvent sourceEvent: sourceEvent
}) })
} }
@@ -83,18 +89,24 @@ items: unknown[] = []
client function moveFocus(sourceEvent, direction) { client function moveFocus(sourceEvent, direction) {
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")] window.setTimeout(function () {
if (!options.length) { if (!root.isConnected || !isOpen()) { return }
return const options = [...root.querySelectorAll('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')]
} if (!options.length) {
const activeIndex = options.indexOf(document.activeElement) return
const nextIndex = (activeIndex + direction + options.length) % options.length }
options[nextIndex].focus() const activeIndex = options.indexOf(document.activeElement)
const nextIndex = activeIndex < 0 ? (direction > 0 ? 0 : options.length - 1) : (activeIndex + direction + options.length) % options.length
options[nextIndex].focus()
}, 0)
} }
client function handleKeydown(sourceEvent) { client function handleKeydown(sourceEvent) {
if (disabled) { return }
if (sourceEvent.key === "Escape") { if (sourceEvent.key === "Escape") {
if (!isOpen()) { return }
sourceEvent.preventDefault() sourceEvent.preventDefault()
sourceEvent.stopPropagation()
hideMenu("escape", sourceEvent) hideMenu("escape", sourceEvent)
} else if (sourceEvent.key === "ArrowDown") { } else if (sourceEvent.key === "ArrowDown") {
sourceEvent.preventDefault() sourceEvent.preventDefault()
@@ -111,19 +123,26 @@ items: unknown[] = []
} else if (sourceEvent.key === "Home" && isOpen()) { } else if (sourceEvent.key === "Home" && isOpen()) {
sourceEvent.preventDefault() sourceEvent.preventDefault()
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const first = root.querySelector("[data-dropdown-item]:not([disabled])") const first = root.querySelector('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')
if (first) { if (first) {
first.focus() first.focus()
} }
} else if (sourceEvent.key === "End" && isOpen()) { } else if (sourceEvent.key === "End" && isOpen()) {
sourceEvent.preventDefault() sourceEvent.preventDefault()
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")] const options = [...root.querySelectorAll('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')]
if (options.length) { if (options.length) {
options[options.length - 1].focus() options[options.length - 1].focus()
} }
} }
} }
client function handleFocusout(sourceEvent) {
const root = sourceEvent.currentTarget
if (isOpen() && sourceEvent.relatedTarget && !root.contains(sourceEvent.relatedTarget)) {
hideMenu("focusout", sourceEvent)
}
}
} }
view { view {
@@ -138,12 +157,14 @@ items: unknown[] = []
data-variant='{variant}' data-variant='{variant}'
class='wrn-dropdown {class}' class='wrn-dropdown {class}'
@keydown='handleKeydown(event)' @keydown='handleKeydown(event)'
@focusout='handleFocusout(event)'
> >
<button <button
type="button" type="button"
class="wrn-dropdown__trigger" class="wrn-dropdown__trigger"
disabled='{disabled}' disabled='{disabled}'
aria-haspopup="menu" aria-haspopup="menu"
aria-label='{ariaLabel || label}'
aria-expanded='{open || visible ? "true" : "false"}' aria-expanded='{open || visible ? "true" : "false"}'
@click='toggleMenu(event)' @click='toggleMenu(event)'
> >
@@ -194,6 +215,7 @@ items: unknown[] = []
data-danger='{item.danger ? "true" : "false"}' data-danger='{item.danger ? "true" : "false"}'
data-selected='{item.selected || item.checked ? "true" : "false"}' data-selected='{item.selected || item.checked ? "true" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}' aria-disabled='{item.disabled ? "true" : "false"}'
tabindex='{item.disabled ? "-1" : "0"}'
class="wrn-dropdown__item" class="wrn-dropdown__item"
@click='chooseItem(item, itemIndex, event)' @click='chooseItem(item, itemIndex, event)'
> >
@@ -460,7 +482,7 @@ items: unknown[] = []
border: 0; border: 0;
border-radius: 0.72rem; border-radius: 0.72rem;
font: inherit; font: inherit;
text-align: left; text-align: start;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; transition: background-color 150ms ease, color 150ms ease, transform 150ms ease;
@@ -550,7 +572,7 @@ items: unknown[] = []
} }
@media (max-width: 639px) { @media (max-width: 639px) {
.wrn-dropdown__panel { .wrn-dropdown[data-placement] .wrn-dropdown__panel {
position: fixed; position: fixed;
right: 0.75rem; right: 0.75rem;
bottom: 0.75rem; bottom: 0.75rem;
+8
View File
@@ -475,6 +475,7 @@ component Footer {
} }
.wrn-footer__copyright { .wrn-footer__copyright {
grid-column: 2;
margin: 0; margin: 0;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
@@ -502,7 +503,12 @@ component Footer {
text-underline-offset: 0.2em; text-underline-offset: 0.2em;
} }
.wrn-footer__copyright-left {
grid-column: 1;
}
.wrn-footer__copyright-right { .wrn-footer__copyright-right {
grid-column: 3;
justify-content: flex-end; justify-content: flex-end;
text-align: right; text-align: right;
} }
@@ -545,10 +551,12 @@ component Footer {
} }
.wrn-footer__copyright-left, .wrn-footer__copyright-left,
.wrn-footer__copyright-right { .wrn-footer__copyright-right {
grid-column: 1;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
} }
.wrn-footer__copyright { .wrn-footer__copyright {
grid-column: 1;
white-space: normal; white-space: normal;
} }
} }
+113 -11
View File
@@ -30,6 +30,8 @@ component Sidebar {
active: string = "" active: string = ""
mobileLabel: string = "Open navigation" mobileLabel: string = "Open navigation"
drawerTitle: string = "Navigation" drawerTitle: string = "Navigation"
mobile: boolean = true
drawerPlacement: string = "left"
class: string = "" class: string = ""
} }
@@ -60,14 +62,16 @@ component Sidebar {
return Boolean(entry.value) && entry.value === active return Boolean(entry.value) && entry.value === active
} }
client function choose(entry, level) { client function choose(entry, level, sourceEvent) {
if (entry.disabled) { if (entry.disabled) {
sourceEvent.preventDefault()
return return
} }
output.select({ item: entry, value: entry.value || "", level: level }) output.select({ item: entry, value: entry.value || "", level: level })
if (drawerOpen) { if (drawerOpen) {
drawerOpen = false drawerOpen = false
output.close({ source: "select" }) output.close({ source: "select" })
output.toggle({ open: false })
} }
} }
@@ -91,6 +95,7 @@ component Sidebar {
class='wrn-sidebar {class}' class='wrn-sidebar {class}'
data-color='{color}' data-color='{color}'
data-size='{size}' data-size='{size}'
data-mobile='{mobile ? "true" : "false"}'
> >
<button <button
type="button" type="button"
@@ -117,7 +122,8 @@ component Sidebar {
data-active='{isActive(entry)}' data-active='{isActive(entry)}'
aria-current='{isActive(entry) ? "page" : "false"}' aria-current='{isActive(entry) ? "page" : "false"}'
aria-disabled='{entry.disabled ? "true" : "false"}' aria-disabled='{entry.disabled ? "true" : "false"}'
@click='choose(entry, 1)' tabindex='{entry.disabled ? "-1" : "0"}'
@click='choose(entry, 1, event)'
> >
<span <span
class='wrn-sidebar__icon {entry.icon}' class='wrn-sidebar__icon {entry.icon}'
@@ -137,7 +143,9 @@ component Sidebar {
data-active='{isActive(child)}' data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}' aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}' aria-disabled='{child.disabled ? "true" : "false"}'
@click='choose(child, 2)' data-wrn-roving-item="true"
tabindex='{child.disabled ? "-1" : "0"}'
@click='choose(child, 2, event)'
> >
<span <span
class='wrn-sidebar__icon {child.icon}' class='wrn-sidebar__icon {child.icon}'
@@ -159,7 +167,10 @@ component Sidebar {
href='{leaf.href || "#"}' href='{leaf.href || "#"}'
data-active='{isActive(leaf)}' data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}' aria-current='{isActive(leaf) ? "page" : "false"}'
@click='choose(leaf, 3)' aria-disabled='{leaf.disabled ? "true" : "false"}'
data-wrn-roving-item="true"
tabindex='{leaf.disabled ? "-1" : "0"}'
@click='choose(leaf, 3, event)'
> >
<span class="wrn-sidebar__label">{leaf.label}</span> <span class="wrn-sidebar__label">{leaf.label}</span>
</a> </a>
@@ -175,15 +186,94 @@ component Sidebar {
<slot /> <slot />
</nav> </nav>
{#if mobile}
<Drawer <Drawer
open={drawerOpen} open={drawerOpen}
placement="left" placement={drawerPlacement}
title={drawerTitle} title={drawerTitle}
label={label} label={label}
@close="closeDrawer()" @close="closeDrawer()"
> >
<nav class="wrn-sidebar__drawer-nav" aria-label='{label}'>
<ul class="wrn-sidebar__list" data-wrn-roving="vertical">
{#each entryList() as entry}
<li class="wrn-sidebar__entry" data-group='{isGroup(entry)}'>
<p class="wrn-sidebar__heading" data-show="entry.heading">{entry.heading}</p>
<a
class="wrn-sidebar__link"
data-show="!entry.heading"
href='{entry.href || "#"}'
data-wrn-roving-item="true"
data-active='{isActive(entry)}'
aria-current='{isActive(entry) ? "page" : "false"}'
aria-disabled='{entry.disabled ? "true" : "false"}'
tabindex='{entry.disabled ? "-1" : "0"}'
@click='choose(entry, 1, event)'
>
<span
class='wrn-sidebar__icon {entry.icon}'
data-show="entry.icon"
aria-hidden="true"
></span>
<span class="wrn-sidebar__label">{entry.label}</span>
<span class="wrn-sidebar__badge" data-show="entry.badge">{entry.badge}</span>
</a>
<ul class="wrn-sidebar__sublist" data-show="childrenOf(entry).length > 0">
{#each childrenOf(entry) as child}
<li class="wrn-sidebar__entry">
<a
class="wrn-sidebar__link"
href='{child.href || "#"}'
data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}'
data-wrn-roving-item="true"
tabindex='{child.disabled ? "-1" : "0"}'
@click='choose(child, 2, event)'
>
<span
class='wrn-sidebar__icon {child.icon}'
data-show="child.icon"
aria-hidden="true"
></span>
<span class="wrn-sidebar__label">{child.label}</span>
<span class="wrn-sidebar__badge" data-show="child.badge">{child.badge}</span>
</a>
<ul
class="wrn-sidebar__sublist wrn-sidebar__sublist--level3"
data-show="childrenOf(child).length > 0"
>
{#each childrenOf(child) as leaf}
<li class="wrn-sidebar__entry">
<a
class="wrn-sidebar__link"
href='{leaf.href || "#"}'
data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}'
aria-disabled='{leaf.disabled ? "true" : "false"}'
data-wrn-roving-item="true"
tabindex='{leaf.disabled ? "-1" : "0"}'
@click='choose(leaf, 3, event)'
>
<span class="wrn-sidebar__label">{leaf.label}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</nav>
<slot name="drawer"></slot> <slot name="drawer"></slot>
</Drawer> </Drawer>
{/if}
</div> </div>
} }
@@ -231,9 +321,21 @@ component Sidebar {
} }
.wrn-sidebar__sublist { .wrn-sidebar__sublist {
margin-left: 0.85rem; margin-inline-start: 0.85rem;
padding-left: 0.5rem; padding-inline-start: 0.5rem;
border-left: 1px solid var(--wrn-color-border); border-inline-start: 1px solid var(--wrn-color-border);
}
.wrn-sidebar__entry[data-group="true"] > .wrn-sidebar__sublist {
margin-inline-start: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
.wrn-sidebar__icon {
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
} }
.wrn-sidebar__entry[data-group="true"] + .wrn-sidebar__entry { .wrn-sidebar__entry[data-group="true"] + .wrn-sidebar__entry {
@@ -286,7 +388,7 @@ component Sidebar {
} }
.wrn-sidebar__badge { .wrn-sidebar__badge {
margin-left: auto; margin-inline-start: auto;
padding: 0.05rem 0.4rem; padding: 0.05rem 0.4rem;
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent); background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
@@ -323,11 +425,11 @@ component Sidebar {
* the focus trap and the scroll lock. * the focus trap and the scroll lock.
*/ */
@media (max-width: 767px) { @media (max-width: 767px) {
.wrn-sidebar__launcher { .wrn-sidebar[data-mobile="true"] > .wrn-sidebar__launcher {
display: inline-flex; display: inline-flex;
} }
.wrn-sidebar__rail { .wrn-sidebar[data-mobile="true"] > .wrn-sidebar__rail {
display: none; display: none;
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.8.56", "version": "0.8.57",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -0,0 +1,170 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { renderComponent, mountHtml } from "../../test/src/index.ts";
import { uiComponentPath } from "../src/registry.ts";
import { parse } from "../../compiler/src/index.ts";
const source = (name: string) => readFileSync(uiComponentPath(name), "utf8");
describe("account shell UI regressions", () => {
test("Sidebar supplies navigation to its mobile Drawer without an app slot", async () => {
const dom = mountHtml(
await renderComponent(source("Sidebar"), {
items: [
{ heading: "Account", items: [{ label: "Profile", href: "/profile", value: "profile" }] },
],
active: "profile",
}),
);
const mobileNav = dom.querySelector(".wrn-sidebar__drawer-nav");
expect(mobileNav?.querySelector('a[href="/profile"]')?.getAttribute("aria-current")).toBe(
"page",
);
expect(dom.querySelector('.wrn-sidebar__rail a[href="/profile"]')).not.toBeNull();
});
test("Sidebar embedded in another drawer does not generate a second mobile drawer", async () => {
const dom = mountHtml(
await renderComponent(source("Sidebar"), {
mobile: false,
items: [{ label: "Profile", href: "/profile" }],
}),
);
expect(dom.querySelector('[data-component="Drawer"]')).toBeNull();
expect(dom.querySelector(".wrn-sidebar")?.getAttribute("data-mobile")).toBe("false");
});
test("Sidebar blocks disabled links at every nesting level", async () => {
const dom = mountHtml(
await renderComponent(source("Sidebar"), {
items: [
{
label: "Parent",
disabled: true,
href: "/parent",
items: [
{
label: "Child",
disabled: true,
href: "/child",
items: [{ label: "Leaf", disabled: true, href: "/leaf" }],
},
],
},
],
}),
);
for (const href of ["/parent", "/child", "/leaf"]) {
const link = dom.querySelector(`.wrn-sidebar__rail a[href="${href}"]`)!;
expect(link.getAttribute("aria-disabled")).toBe("true");
expect(link.getAttribute("tabindex")).toBe("-1");
const click = new (dom.window as { MouseEvent: typeof MouseEvent }).MouseEvent("click", {
bubbles: true,
cancelable: true,
});
link.dispatchEvent(click);
expect(click.defaultPrevented).toBe(true);
}
});
test("Dropdown keyboard navigation skips disabled links and ArrowUp starts at the last item", async () => {
const dom = mountHtml(
await renderComponent(source("Dropdown"), {
label: "Account",
items: [
{ label: "Profile", href: "/profile" },
{ label: "Disabled", href: "/disabled", disabled: true },
{ label: "Security", href: "/security" },
],
}),
);
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
// Execute the component's real JS functions: the lightweight mount runtime
// intentionally does not interpret nested timer callbacks.
const functions = parse(source("Dropdown"))
.functions.join("\n")
.replace(/\b(client|shared) function/g, "function");
const handle = new Function(
"document",
"window",
"output",
`
let open = false, visible = false, disabled = false;
${functions}
return handleKeydown;
`,
)(dom.document, dom.window, {
open() {},
close() {},
toggle({ open }: { open: boolean }) {
trigger.setAttribute("aria-expanded", String(open));
},
});
const key = (_target: Element, value: string) =>
handle({
key: value,
currentTarget: dom.querySelector(".wrn-dropdown"),
preventDefault() {},
stopPropagation() {},
});
trigger.focus();
key(trigger, "ArrowUp");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(dom.document.activeElement?.textContent).toContain("Security");
key(dom.document.activeElement!, "ArrowUp");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(dom.document.activeElement?.textContent).toContain("Profile");
key(dom.document.activeElement!, "Escape");
expect(dom.document.activeElement).toBe(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
test("Dropdown preserves falsy action values and has an explicit accessible trigger name", async () => {
const dom = mountHtml(
await renderComponent(source("Dropdown"), {
label: "",
ariaLabel: "Account menu",
items: [{ label: "Zero", value: 0, action: true }],
}),
);
let value: unknown = undefined;
dom.querySelector(".wrn-dropdown")!.addEventListener("action", (event) => {
value = (event as CustomEvent).detail.value;
});
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
expect(trigger.getAttribute("aria-label")).toBe("Account menu");
trigger.click();
(dom.querySelector("[data-dropdown-item]") as HTMLButtonElement).click();
expect(value).toBe(0);
});
test("Dropdown closes when keyboard focus leaves the component", async () => {
const dom = mountHtml(
await renderComponent(source("Dropdown"), {
label: "Menu",
items: [{ label: "Profile", href: "/profile" }],
}),
);
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
trigger.click();
const outside = dom.document.createElement("button");
dom.document.body.append(outside);
trigger.dispatchEvent(
new (dom.window as { FocusEvent: typeof FocusEvent }).FocusEvent("focusout", {
bubbles: true,
relatedTarget: outside,
}),
);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
test("layout fixes belong to components, not consuming applications", () => {
expect(source("Sidebar")).toMatch(
/\.wrn-sidebar__icon\s*\{[^}]*width: 1.05rem;[^}]*height: 1.05rem;/s,
);
expect(source("Sidebar")).toContain("margin-inline-start");
expect(source("Dropdown")).toContain(".wrn-dropdown[data-placement] .wrn-dropdown__panel");
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-left\s*\{\s*grid-column: 1;/);
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-right\s*\{\s*grid-column: 3;/);
});
});