ngwr 14.9.0 → 14.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/editor/styles/_index.scss +16 -1
- package/fesm2022/ngwr-editor-testing.mjs +132 -53
- package/fesm2022/ngwr-editor-testing.mjs.map +1 -1
- package/fesm2022/ngwr-editor.mjs +119 -20
- package/fesm2022/ngwr-editor.mjs.map +1 -1
- package/fesm2022/ngwr-sidebar.mjs +7 -2
- package/fesm2022/ngwr-sidebar.mjs.map +1 -1
- package/fesm2022/ngwr-version.mjs +1 -1
- package/fesm2022/ngwr-version.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngwr-editor-testing.d.ts +79 -33
- package/types/ngwr-editor.d.ts +63 -9
- package/types/ngwr-sidebar.d.ts +25 -0
- package/types/ngwr-version.d.ts +1 -1
|
@@ -93,6 +93,11 @@ class WrSidebar {
|
|
|
93
93
|
/** Auto-expand the group containing the active route. @default true */
|
|
94
94
|
autoExpand = input(true, { ...(ngDevMode ? { debugName: "autoExpand" } : /* istanbul ignore next */ {}), transform: coerceBooleanProperty });
|
|
95
95
|
isGroup = isGroup;
|
|
96
|
+
/**
|
|
97
|
+
* What an entry without its own `activeOptions` gets. It is the router's own
|
|
98
|
+
* default, written out because a binding cannot be left unset per entry.
|
|
99
|
+
*/
|
|
100
|
+
defaultActiveOptions = { exact: false };
|
|
96
101
|
opened = signal(new Set(), /* @ts-ignore */
|
|
97
102
|
...(ngDevMode ? [{ debugName: "opened" }] : /* istanbul ignore next */ []));
|
|
98
103
|
/** Group titles already seeded from `defaultOpen`, so it seeds once and not again. */
|
|
@@ -194,11 +199,11 @@ class WrSidebar {
|
|
|
194
199
|
return null;
|
|
195
200
|
}
|
|
196
201
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: WrSidebar, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
197
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: WrSidebar, isStandalone: true, selector: "wr-sidebar", inputs: { entries: { classPropertyName: "entries", publicName: "entries", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, defaultGroupIcon: { classPropertyName: "defaultGroupIcon", publicName: "defaultGroupIcon", isSignal: true, isRequired: false, transformFunction: null }, defaultItemIcon: { classPropertyName: "defaultItemIcon", publicName: "defaultItemIcon", isSignal: true, isRequired: false, transformFunction: null }, autoExpand: { classPropertyName: "autoExpand", publicName: "autoExpand", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "navigation" }, properties: { "attr.aria-label": "resolvedAriaLabel()" }, classAttribute: "wr-sidebar" }, ngImport: i0, template: "<!-- A div, not a <nav>: the host already IS the navigation landmark, and two\n nested ones announce the same list twice. -->\n<div class=\"wr-sidebar__inner\">\n @for (entry of entries(); track entry.title) {\n @if (!isGroup(entry)) {\n <!--\n A span, not a greyed anchor, exactly as the nested branch below does it.\n `--disabled` only paints: `pointer-events: none` stops a real pointer but\n not the keyboard, so a disabled entry stayed in the tab order and Enter\n navigated \u2014 `disabled` on a `WrSidebarItem` is documented as \"no\n navigation\". An empty `url` is the same case: `[routerLink]=\"[]\"` resolves\n to the current route, which is a link that goes nowhere.\n -->\n @if (asItem(entry).disabled || !asItem(entry).url.length) {\n <span class=\"wr-sidebar__entry wr-sidebar__entry--disabled\">\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </span>\n } @else {\n <a
|
|
202
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: WrSidebar, isStandalone: true, selector: "wr-sidebar", inputs: { entries: { classPropertyName: "entries", publicName: "entries", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, defaultGroupIcon: { classPropertyName: "defaultGroupIcon", publicName: "defaultGroupIcon", isSignal: true, isRequired: false, transformFunction: null }, defaultItemIcon: { classPropertyName: "defaultItemIcon", publicName: "defaultItemIcon", isSignal: true, isRequired: false, transformFunction: null }, autoExpand: { classPropertyName: "autoExpand", publicName: "autoExpand", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "navigation" }, properties: { "attr.aria-label": "resolvedAriaLabel()" }, classAttribute: "wr-sidebar" }, ngImport: i0, template: "<!-- A div, not a <nav>: the host already IS the navigation landmark, and two\n nested ones announce the same list twice. -->\n<div class=\"wr-sidebar__inner\">\n @for (entry of entries(); track entry.title) {\n @if (!isGroup(entry)) {\n <!--\n A span, not a greyed anchor, exactly as the nested branch below does it.\n `--disabled` only paints: `pointer-events: none` stops a real pointer but\n not the keyboard, so a disabled entry stayed in the tab order and Enter\n navigated \u2014 `disabled` on a `WrSidebarItem` is documented as \"no\n navigation\". An empty `url` is the same case: `[routerLink]=\"[]\"` resolves\n to the current route, which is a link that goes nowhere.\n -->\n @if (asItem(entry).disabled || !asItem(entry).url.length) {\n <span class=\"wr-sidebar__entry wr-sidebar__entry--disabled\">\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </span>\n } @else {\n <a\n class=\"wr-sidebar__entry\"\n routerLinkActive=\"wr-sidebar__entry--active\"\n [routerLink]=\"asItem(entry).url\"\n [routerLinkActiveOptions]=\"asItem(entry).activeOptions ?? defaultActiveOptions\"\n >\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </a>\n }\n } @else {\n <section class=\"wr-sidebar__group\" [class.wr-sidebar__group--collapsed]=\"!isOpen(entry.title)\">\n <button\n type=\"button\"\n class=\"wr-sidebar__group-toggle\"\n [attr.aria-expanded]=\"isOpen(entry.title)\"\n [attr.aria-controls]=\"groupBodyId($index)\"\n (click)=\"toggleGroup(entry.title)\"\n >\n @if (asGroup(entry).icon || defaultGroupIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n <!-- Inline chrome chevron \u2014 not user-customizable. -->\n <svg\n class=\"wr-icon__svg wr-sidebar__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n <div\n class=\"wr-sidebar__group-wrap\"\n [attr.id]=\"groupBodyId($index)\"\n [attr.inert]=\"!isOpen(entry.title) ? '' : null\"\n >\n <div class=\"wr-sidebar__group-body\">\n <ul class=\"wr-sidebar__list\">\n @for (child of asGroup(entry).children; track child.title) {\n <li>\n @if (child.disabled || !child.url.length) {\n <span class=\"wr-sidebar__item wr-sidebar__item--disabled\">\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </span>\n } @else {\n <a\n class=\"wr-sidebar__item\"\n routerLinkActive=\"wr-sidebar__item--active\"\n [routerLink]=\"child.url\"\n [routerLinkActiveOptions]=\"child.activeOptions ?? defaultActiveOptions\"\n >\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </a>\n }\n </li>\n }\n </ul>\n </div>\n </div>\n </section>\n }\n }\n</div>\n", dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "component", type: WrIcon, selector: "wr-icon", inputs: ["name"] }], encapsulation: i0.ViewEncapsulation.None });
|
|
198
203
|
}
|
|
199
204
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: WrSidebar, decorators: [{
|
|
200
205
|
type: Component,
|
|
201
|
-
args: [{ selector: 'wr-sidebar', encapsulation: ViewEncapsulation.None, host: { class: 'wr-sidebar', role: 'navigation', '[attr.aria-label]': 'resolvedAriaLabel()' }, imports: [RouterLink, RouterLinkActive, WrIcon], template: "<!-- A div, not a <nav>: the host already IS the navigation landmark, and two\n nested ones announce the same list twice. -->\n<div class=\"wr-sidebar__inner\">\n @for (entry of entries(); track entry.title) {\n @if (!isGroup(entry)) {\n <!--\n A span, not a greyed anchor, exactly as the nested branch below does it.\n `--disabled` only paints: `pointer-events: none` stops a real pointer but\n not the keyboard, so a disabled entry stayed in the tab order and Enter\n navigated \u2014 `disabled` on a `WrSidebarItem` is documented as \"no\n navigation\". An empty `url` is the same case: `[routerLink]=\"[]\"` resolves\n to the current route, which is a link that goes nowhere.\n -->\n @if (asItem(entry).disabled || !asItem(entry).url.length) {\n <span class=\"wr-sidebar__entry wr-sidebar__entry--disabled\">\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </span>\n } @else {\n <a
|
|
206
|
+
args: [{ selector: 'wr-sidebar', encapsulation: ViewEncapsulation.None, host: { class: 'wr-sidebar', role: 'navigation', '[attr.aria-label]': 'resolvedAriaLabel()' }, imports: [RouterLink, RouterLinkActive, WrIcon], template: "<!-- A div, not a <nav>: the host already IS the navigation landmark, and two\n nested ones announce the same list twice. -->\n<div class=\"wr-sidebar__inner\">\n @for (entry of entries(); track entry.title) {\n @if (!isGroup(entry)) {\n <!--\n A span, not a greyed anchor, exactly as the nested branch below does it.\n `--disabled` only paints: `pointer-events: none` stops a real pointer but\n not the keyboard, so a disabled entry stayed in the tab order and Enter\n navigated \u2014 `disabled` on a `WrSidebarItem` is documented as \"no\n navigation\". An empty `url` is the same case: `[routerLink]=\"[]\"` resolves\n to the current route, which is a link that goes nowhere.\n -->\n @if (asItem(entry).disabled || !asItem(entry).url.length) {\n <span class=\"wr-sidebar__entry wr-sidebar__entry--disabled\">\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </span>\n } @else {\n <a\n class=\"wr-sidebar__entry\"\n routerLinkActive=\"wr-sidebar__entry--active\"\n [routerLink]=\"asItem(entry).url\"\n [routerLinkActiveOptions]=\"asItem(entry).activeOptions ?? defaultActiveOptions\"\n >\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </a>\n }\n } @else {\n <section class=\"wr-sidebar__group\" [class.wr-sidebar__group--collapsed]=\"!isOpen(entry.title)\">\n <button\n type=\"button\"\n class=\"wr-sidebar__group-toggle\"\n [attr.aria-expanded]=\"isOpen(entry.title)\"\n [attr.aria-controls]=\"groupBodyId($index)\"\n (click)=\"toggleGroup(entry.title)\"\n >\n @if (asGroup(entry).icon || defaultGroupIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n <!-- Inline chrome chevron \u2014 not user-customizable. -->\n <svg\n class=\"wr-icon__svg wr-sidebar__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n <div\n class=\"wr-sidebar__group-wrap\"\n [attr.id]=\"groupBodyId($index)\"\n [attr.inert]=\"!isOpen(entry.title) ? '' : null\"\n >\n <div class=\"wr-sidebar__group-body\">\n <ul class=\"wr-sidebar__list\">\n @for (child of asGroup(entry).children; track child.title) {\n <li>\n @if (child.disabled || !child.url.length) {\n <span class=\"wr-sidebar__item wr-sidebar__item--disabled\">\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </span>\n } @else {\n <a\n class=\"wr-sidebar__item\"\n routerLinkActive=\"wr-sidebar__item--active\"\n [routerLink]=\"child.url\"\n [routerLinkActiveOptions]=\"child.activeOptions ?? defaultActiveOptions\"\n >\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </a>\n }\n </li>\n }\n </ul>\n </div>\n </div>\n </section>\n }\n }\n</div>\n" }]
|
|
202
207
|
}], ctorParameters: () => [], propDecorators: { entries: [{ type: i0.Input, args: [{ isSignal: true, alias: "entries", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], defaultGroupIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultGroupIcon", required: false }] }], defaultItemIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultItemIcon", required: false }] }], autoExpand: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoExpand", required: false }] }] } });
|
|
203
208
|
|
|
204
209
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ngwr-sidebar.mjs","sources":["../../../projects/lib/sidebar/sidebar.ts","../../../projects/lib/sidebar/sidebar.html","../../../projects/lib/sidebar/ngwr-sidebar.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport { coerceBooleanProperty } from '@angular/cdk/coercion';\nimport { Component, ViewEncapsulation, effect, inject, input, signal } from '@angular/core';\nimport { toSignal } from '@angular/core/rxjs-interop';\nimport { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';\n\nimport { filter, map, startWith } from 'rxjs';\n\nimport { useI18nText } from 'ngwr/i18n';\nimport { WrIcon } from 'ngwr/icon';\n\nimport type { WrSidebarEntry, WrSidebarGroup, WrSidebarItem } from './interfaces';\n\nfunction isGroup(entry: WrSidebarEntry): entry is WrSidebarGroup {\n return 'children' in entry;\n}\n\n/**\n * Instance counter for the group-body ids. Deterministic under prerender, unlike\n * `randomId()`, and unique across sidebars — a title-only id collided between two\n * sidebars on one page, and `aria-controls` then pointed at the wrong body.\n */\nlet sidebarUid = 0;\n\n/**\n * A `routerLink` segment array as an absolute path.\n *\n * The documented form carries the leading slash on the FIRST segment\n * (`['/settings', 'profile']`), and prefixing another one produced\n * `//settings/profile` — a path no URL ever equals, which is why auto-expand\n * never fired for anyone following the example. Both forms normalise here.\n */\nfunction routePath(segments: readonly (string | number)[]): string {\n return `/${segments.join('/')}`.replace(/\\/{2,}/g, '/');\n}\n\n/**\n * Whether `url` is `route` or something below it. A bare `startsWith` matched a\n * SIBLING whose path merely began the same way, so visiting `/table` expanded the\n * group that owns `/tabs`.\n */\nfunction matchesRoute(url: string, route: string): boolean {\n if (!url.startsWith(route)) return false;\n const rest = url.slice(route.length);\n return rest === '' || rest.startsWith('/') || rest.startsWith('?') || rest.startsWith('#');\n}\n\n/**\n * Data-driven sidebar nav. Pass `[entries]` — either flat\n * `WrSidebarItem`s (direct links) or `WrSidebarGroup`s (expand to reveal\n * child items). Active route auto-expands its containing group.\n *\n * The host pads its rows by `--wr-sidebar-padding` (`0.5rem`), so a hovered or\n * active row's tint stays off the edge of whatever holds the nav, a bordered\n * card or a `<wr-layout-sider>`, which pads nothing itself. Where the container\n * already supplies the inset, set the hook to `0` on the sidebar, not on the\n * container: the host declares its own default, which shadows an inherited one\n * (`.my-panel .wr-sidebar { --wr-sidebar-padding: 0 }`, or inline).\n *\n * @example\n * ```html\n * <wr-sidebar [entries]=\"[\n * { title: 'Dashboard', icon: 'home', url: ['/dashboard'] },\n * {\n * title: 'Settings',\n * icon: 'cog',\n * children: [\n * { title: 'Profile', url: ['/settings', 'profile'] },\n * { title: 'Billing', url: ['/settings', 'billing'] }\n * ]\n * }\n * ]\" />\n * ```\n *\n * @see https://ngwr.dev/reference/components/sidebar\n */\n@Component({\n selector: 'wr-sidebar',\n templateUrl: './sidebar.html',\n encapsulation: ViewEncapsulation.None,\n host: { class: 'wr-sidebar', role: 'navigation', '[attr.aria-label]': 'resolvedAriaLabel()' },\n imports: [RouterLink, RouterLinkActive, WrIcon],\n})\nexport class WrSidebar {\n readonly entries = input<readonly WrSidebarEntry[]>([]);\n\n /**\n * Accessible name for the navigation landmark. Falls back to `sidebar.label`,\n * then `'Sidebar'` — a page with a sidebar AND a table of contents has two\n * navigation landmarks, and an unnamed one is announced as just \"navigation\".\n */\n readonly ariaLabel = input<string | null>(null);\n\n protected readonly resolvedAriaLabel = useI18nText(this.ariaLabel, 'sidebar.label', 'Sidebar');\n\n /** Default icon for entries without one. @default 'folder' */\n readonly defaultGroupIcon = input<string>('folder');\n\n /** Default icon for items without one. @default 'caret-forward' */\n readonly defaultItemIcon = input<string>('caret-forward');\n\n /** Auto-expand the group containing the active route. @default true */\n readonly autoExpand = input(true, { transform: coerceBooleanProperty });\n\n protected readonly isGroup = isGroup;\n\n private readonly opened = signal<ReadonlySet<string>>(new Set());\n\n /** Group titles already seeded from `defaultOpen`, so it seeds once and not again. */\n private readonly seeded = new Set<string>();\n\n private readonly uid = ++sidebarUid;\n\n private readonly router = inject(Router);\n private readonly currentUrl = toSignal(\n this.router.events.pipe(\n filter((e): e is NavigationEnd => e instanceof NavigationEnd),\n map(e => e.urlAfterRedirects),\n startWith(this.router.url)\n ),\n { initialValue: this.router.url }\n );\n\n /**\n * Last URL we resolved to a group — prevents re-opening one the user collapsed.\n * A URL no entry claims is deliberately NOT recorded: see the effect below.\n */\n private lastAutoUrl: string | null = null;\n\n constructor() {\n // Seed `defaultOpen` groups — once per group, not once per `entries` emission.\n // Re-seeding meant any later change to the array (a badge count, a filtered\n // list) re-opened a group the user had deliberately collapsed.\n effect(() => {\n const fresh: string[] = [];\n for (const entry of this.entries()) {\n if (isGroup(entry) && entry.defaultOpen && !this.seeded.has(entry.title)) {\n this.seeded.add(entry.title);\n fresh.push(entry.title);\n }\n }\n if (fresh.length > 0) {\n this.opened.update(prev => new Set([...prev, ...fresh]));\n }\n });\n\n // Auto-expand the group containing the current URL.\n effect(() => {\n if (!this.autoExpand()) return;\n const url = this.currentUrl();\n if (url === this.lastAutoUrl) return;\n // Latch AFTER the lookup, never before it. `entries` often arrives from an\n // HTTP call, so the first run matches nothing — and latching first told the\n // re-run that landed with the real nav that this URL was already handled.\n // A deep link into a `defaultOpen: false` group then stayed collapsed, with\n // the active link out of the tab order, until the user navigated away and\n // back. Only a run that found a group has actually handled the URL; the\n // user-collapse latch is unaffected, since that run is also the one that\n // records it.\n const match = this.findGroupForUrl(url);\n if (!match) return;\n this.lastAutoUrl = url;\n if (this.opened().has(match)) return;\n this.opened.update(prev => {\n const next = new Set(prev);\n next.add(match);\n return next;\n });\n });\n }\n\n protected isOpen(title: string): boolean {\n return this.opened().has(title);\n }\n\n /**\n * Stable id for the group body, referenced by the toggle's `aria-controls`.\n *\n * Keyed by POSITION, not by the title. Slugging the title kept the id readable\n * but was not injective: it lowercases and collapses every non-`[a-z0-9]` run,\n * so `Data & charts` and `Data / charts` produced the same string, and a title\n * with no ASCII alphanumerics at all — `Компоненты`, `数据` — produced the empty\n * one. A whole Russian or Chinese sidebar therefore emitted N identical ids and\n * every toggle's `aria-controls` resolved to the FIRST group's body. The toggle\n * and its body read `$index` from the same `@for` iteration, so the two always\n * name each other — reordering `entries` renumbers both together.\n */\n protected groupBodyId(index: number): string {\n return `wr-sidebar-${this.uid}-group-${index}`;\n }\n\n protected toggleGroup(title: string): void {\n this.opened.update(prev => {\n const next = new Set(prev);\n if (next.has(title)) next.delete(title);\n else next.add(title);\n return next;\n });\n }\n\n protected asItem(entry: WrSidebarEntry): WrSidebarItem {\n return entry as WrSidebarItem;\n }\n\n protected asGroup(entry: WrSidebarEntry): WrSidebarGroup {\n return entry as WrSidebarGroup;\n }\n\n private findGroupForUrl(url: string): string | null {\n for (const entry of this.entries()) {\n if (!isGroup(entry)) continue;\n const hit = entry.children.some(child => matchesRoute(url, routePath(child.url)));\n if (hit) return entry.title;\n }\n return null;\n }\n}\n","<!-- A div, not a <nav>: the host already IS the navigation landmark, and two\n nested ones announce the same list twice. -->\n<div class=\"wr-sidebar__inner\">\n @for (entry of entries(); track entry.title) {\n @if (!isGroup(entry)) {\n <!--\n A span, not a greyed anchor, exactly as the nested branch below does it.\n `--disabled` only paints: `pointer-events: none` stops a real pointer but\n not the keyboard, so a disabled entry stayed in the tab order and Enter\n navigated — `disabled` on a `WrSidebarItem` is documented as \"no\n navigation\". An empty `url` is the same case: `[routerLink]=\"[]\"` resolves\n to the current route, which is a link that goes nowhere.\n -->\n @if (asItem(entry).disabled || !asItem(entry).url.length) {\n <span class=\"wr-sidebar__entry wr-sidebar__entry--disabled\">\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </span>\n } @else {\n <a class=\"wr-sidebar__entry\" routerLinkActive=\"wr-sidebar__entry--active\" [routerLink]=\"asItem(entry).url\">\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </a>\n }\n } @else {\n <section class=\"wr-sidebar__group\" [class.wr-sidebar__group--collapsed]=\"!isOpen(entry.title)\">\n <button\n type=\"button\"\n class=\"wr-sidebar__group-toggle\"\n [attr.aria-expanded]=\"isOpen(entry.title)\"\n [attr.aria-controls]=\"groupBodyId($index)\"\n (click)=\"toggleGroup(entry.title)\"\n >\n @if (asGroup(entry).icon || defaultGroupIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n <!-- Inline chrome chevron — not user-customizable. -->\n <svg\n class=\"wr-icon__svg wr-sidebar__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n <div\n class=\"wr-sidebar__group-wrap\"\n [attr.id]=\"groupBodyId($index)\"\n [attr.inert]=\"!isOpen(entry.title) ? '' : null\"\n >\n <div class=\"wr-sidebar__group-body\">\n <ul class=\"wr-sidebar__list\">\n @for (child of asGroup(entry).children; track child.title) {\n <li>\n @if (child.disabled || !child.url.length) {\n <span class=\"wr-sidebar__item wr-sidebar__item--disabled\">\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </span>\n } @else {\n <a class=\"wr-sidebar__item\" routerLinkActive=\"wr-sidebar__item--active\" [routerLink]=\"child.url\">\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </a>\n }\n </li>\n }\n </ul>\n </div>\n </div>\n </section>\n }\n }\n</div>\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":";;;;;;;;;AAAA;;;;;AAKG;AAcH,SAAS,OAAO,CAAC,KAAqB,EAAA;IACpC,OAAO,UAAU,IAAI,KAAK;AAC5B;AAEA;;;;AAIG;AACH,IAAI,UAAU,GAAG,CAAC;AAElB;;;;;;;AAOG;AACH,SAAS,SAAS,CAAC,QAAsC,EAAA;AACvD,IAAA,OAAO,CAAA,CAAA,EAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA,CAAE,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC;AACzD;AAEA;;;;AAIG;AACH,SAAS,YAAY,CAAC,GAAW,EAAE,KAAa,EAAA;AAC9C,IAAA,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,CAAC;AAAE,QAAA,OAAO,KAAK;IACxC,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC;IACpC,OAAO,IAAI,KAAK,EAAE,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;AAC5F;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BG;MAQU,SAAS,CAAA;IACX,OAAO,GAAG,KAAK,CAA4B,EAAE;gFAAC;AAEvD;;;;AAIG;IACM,SAAS,GAAG,KAAK,CAAgB,IAAI;kFAAC;IAE5B,iBAAiB,GAAG,WAAW,CAAC,IAAI,CAAC,SAAS,EAAE,eAAe,EAAE,SAAS,CAAC;;IAGrF,gBAAgB,GAAG,KAAK,CAAS,QAAQ;yFAAC;;IAG1C,eAAe,GAAG,KAAK,CAAS,eAAe;wFAAC;;IAGhD,UAAU,GAAG,KAAK,CAAC,IAAI,kFAAI,SAAS,EAAE,qBAAqB,EAAA,CAAG;IAEpD,OAAO,GAAG,OAAO;AAEnB,IAAA,MAAM,GAAG,MAAM,CAAsB,IAAI,GAAG,EAAE;+EAAC;;AAG/C,IAAA,MAAM,GAAG,IAAI,GAAG,EAAU;IAE1B,GAAG,GAAG,EAAE,UAAU;AAElB,IAAA,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IACvB,UAAU,GAAG,QAAQ,CACpC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CACrB,MAAM,CAAC,CAAC,CAAC,KAAyB,CAAC,YAAY,aAAa,CAAC,EAC7D,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,iBAAiB,CAAC,EAC7B,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAC3B,EACD,EAAE,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,CAClC;AAED;;;AAGG;IACK,WAAW,GAAkB,IAAI;AAEzC,IAAA,WAAA,GAAA;;;;QAIE,MAAM,CAAC,MAAK;YACV,MAAM,KAAK,GAAa,EAAE;YAC1B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE;gBAClC,IAAI,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,WAAW,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;oBACxE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC;AAC5B,oBAAA,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC;gBACzB;YACF;AACA,YAAA,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE;gBACpB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,IAAI,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC;YAC1D;AACF,QAAA,CAAC,CAAC;;QAGF,MAAM,CAAC,MAAK;AACV,YAAA,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE;gBAAE;AACxB,YAAA,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,EAAE;AAC7B,YAAA,IAAI,GAAG,KAAK,IAAI,CAAC,WAAW;gBAAE;;;;;;;;;YAS9B,MAAM,KAAK,GAAG,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC;AACvC,YAAA,IAAI,CAAC,KAAK;gBAAE;AACZ,YAAA,IAAI,CAAC,WAAW,GAAG,GAAG;YACtB,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC;gBAAE;AAC9B,YAAA,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,IAAG;AACxB,gBAAA,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC;AAC1B,gBAAA,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC;AACf,gBAAA,OAAO,IAAI;AACb,YAAA,CAAC,CAAC;AACJ,QAAA,CAAC,CAAC;IACJ;AAEU,IAAA,MAAM,CAAC,KAAa,EAAA;QAC5B,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC;IACjC;AAEA;;;;;;;;;;;AAWG;AACO,IAAA,WAAW,CAAC,KAAa,EAAA;AACjC,QAAA,OAAO,cAAc,IAAI,CAAC,GAAG,CAAA,OAAA,EAAU,KAAK,EAAE;IAChD;AAEU,IAAA,WAAW,CAAC,KAAa,EAAA;AACjC,QAAA,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,IAAG;AACxB,YAAA,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC;AAC1B,YAAA,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC;AAAE,gBAAA,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC;;AAClC,gBAAA,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC;AACpB,YAAA,OAAO,IAAI;AACb,QAAA,CAAC,CAAC;IACJ;AAEU,IAAA,MAAM,CAAC,KAAqB,EAAA;AACpC,QAAA,OAAO,KAAsB;IAC/B;AAEU,IAAA,OAAO,CAAC,KAAqB,EAAA;AACrC,QAAA,OAAO,KAAuB;IAChC;AAEQ,IAAA,eAAe,CAAC,GAAW,EAAA;QACjC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE;AAClC,YAAA,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE;YACrB,MAAM,GAAG,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,IAAI,YAAY,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;AACjF,YAAA,IAAI,GAAG;gBAAE,OAAO,KAAK,CAAC,KAAK;QAC7B;AACA,QAAA,OAAO,IAAI;IACb;uGApIW,SAAS,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;AAAT,IAAA,OAAA,IAAA,GAAA,EAAA,CAAA,oBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,SAAS,k5BCzFtB,ugJAqGA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EDdY,UAAU,EAAA,QAAA,EAAA,cAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,aAAA,EAAA,UAAA,EAAA,qBAAA,EAAA,OAAA,EAAA,MAAA,EAAA,YAAA,EAAA,kBAAA,EAAA,oBAAA,EAAA,YAAA,EAAA,YAAA,EAAA,YAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAE,gBAAgB,8MAAE,MAAM,EAAA,QAAA,EAAA,SAAA,EAAA,MAAA,EAAA,CAAA,MAAA,CAAA,EAAA,CAAA,EAAA,aAAA,EAAA,EAAA,CAAA,iBAAA,CAAA,IAAA,EAAA,CAAA;;2FAEnC,SAAS,EAAA,UAAA,EAAA,CAAA;kBAPrB,SAAS;+BACE,YAAY,EAAA,aAAA,EAEP,iBAAiB,CAAC,IAAI,EAAA,IAAA,EAC/B,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,EAAA,OAAA,EACpF,CAAC,UAAU,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAA,QAAA,EAAA,ugJAAA,EAAA;;;AEvFjD;;AAEG;;"}
|
|
1
|
+
{"version":3,"file":"ngwr-sidebar.mjs","sources":["../../../projects/lib/sidebar/sidebar.ts","../../../projects/lib/sidebar/sidebar.html","../../../projects/lib/sidebar/ngwr-sidebar.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport { coerceBooleanProperty } from '@angular/cdk/coercion';\nimport { Component, ViewEncapsulation, effect, inject, input, signal } from '@angular/core';\nimport { toSignal } from '@angular/core/rxjs-interop';\nimport { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';\n\nimport { filter, map, startWith } from 'rxjs';\n\nimport { useI18nText } from 'ngwr/i18n';\nimport { WrIcon } from 'ngwr/icon';\n\nimport type { WrSidebarEntry, WrSidebarGroup, WrSidebarItem } from './interfaces';\n\nfunction isGroup(entry: WrSidebarEntry): entry is WrSidebarGroup {\n return 'children' in entry;\n}\n\n/**\n * Instance counter for the group-body ids. Deterministic under prerender, unlike\n * `randomId()`, and unique across sidebars — a title-only id collided between two\n * sidebars on one page, and `aria-controls` then pointed at the wrong body.\n */\nlet sidebarUid = 0;\n\n/**\n * A `routerLink` segment array as an absolute path.\n *\n * The documented form carries the leading slash on the FIRST segment\n * (`['/settings', 'profile']`), and prefixing another one produced\n * `//settings/profile` — a path no URL ever equals, which is why auto-expand\n * never fired for anyone following the example. Both forms normalise here.\n */\nfunction routePath(segments: readonly (string | number)[]): string {\n return `/${segments.join('/')}`.replace(/\\/{2,}/g, '/');\n}\n\n/**\n * Whether `url` is `route` or something below it. A bare `startsWith` matched a\n * SIBLING whose path merely began the same way, so visiting `/table` expanded the\n * group that owns `/tabs`.\n */\nfunction matchesRoute(url: string, route: string): boolean {\n if (!url.startsWith(route)) return false;\n const rest = url.slice(route.length);\n return rest === '' || rest.startsWith('/') || rest.startsWith('?') || rest.startsWith('#');\n}\n\n/**\n * Data-driven sidebar nav. Pass `[entries]` — either flat\n * `WrSidebarItem`s (direct links) or `WrSidebarGroup`s (expand to reveal\n * child items). Active route auto-expands its containing group.\n *\n * The host pads its rows by `--wr-sidebar-padding` (`0.5rem`), so a hovered or\n * active row's tint stays off the edge of whatever holds the nav, a bordered\n * card or a `<wr-layout-sider>`, which pads nothing itself. Where the container\n * already supplies the inset, set the hook to `0` on the sidebar, not on the\n * container: the host declares its own default, which shadows an inherited one\n * (`.my-panel .wr-sidebar { --wr-sidebar-padding: 0 }`, or inline).\n *\n * @example\n * ```html\n * <wr-sidebar [entries]=\"[\n * { title: 'Dashboard', icon: 'home', url: ['/dashboard'] },\n * {\n * title: 'Settings',\n * icon: 'cog',\n * children: [\n * { title: 'Profile', url: ['/settings', 'profile'] },\n * { title: 'Billing', url: ['/settings', 'billing'] }\n * ]\n * }\n * ]\" />\n * ```\n *\n * @see https://ngwr.dev/reference/components/sidebar\n */\n@Component({\n selector: 'wr-sidebar',\n templateUrl: './sidebar.html',\n encapsulation: ViewEncapsulation.None,\n host: { class: 'wr-sidebar', role: 'navigation', '[attr.aria-label]': 'resolvedAriaLabel()' },\n imports: [RouterLink, RouterLinkActive, WrIcon],\n})\nexport class WrSidebar {\n readonly entries = input<readonly WrSidebarEntry[]>([]);\n\n /**\n * Accessible name for the navigation landmark. Falls back to `sidebar.label`,\n * then `'Sidebar'` — a page with a sidebar AND a table of contents has two\n * navigation landmarks, and an unnamed one is announced as just \"navigation\".\n */\n readonly ariaLabel = input<string | null>(null);\n\n protected readonly resolvedAriaLabel = useI18nText(this.ariaLabel, 'sidebar.label', 'Sidebar');\n\n /** Default icon for entries without one. @default 'folder' */\n readonly defaultGroupIcon = input<string>('folder');\n\n /** Default icon for items without one. @default 'caret-forward' */\n readonly defaultItemIcon = input<string>('caret-forward');\n\n /** Auto-expand the group containing the active route. @default true */\n readonly autoExpand = input(true, { transform: coerceBooleanProperty });\n\n protected readonly isGroup = isGroup;\n\n /**\n * What an entry without its own `activeOptions` gets. It is the router's own\n * default, written out because a binding cannot be left unset per entry.\n */\n protected readonly defaultActiveOptions = { exact: false };\n\n private readonly opened = signal<ReadonlySet<string>>(new Set());\n\n /** Group titles already seeded from `defaultOpen`, so it seeds once and not again. */\n private readonly seeded = new Set<string>();\n\n private readonly uid = ++sidebarUid;\n\n private readonly router = inject(Router);\n private readonly currentUrl = toSignal(\n this.router.events.pipe(\n filter((e): e is NavigationEnd => e instanceof NavigationEnd),\n map(e => e.urlAfterRedirects),\n startWith(this.router.url)\n ),\n { initialValue: this.router.url }\n );\n\n /**\n * Last URL we resolved to a group — prevents re-opening one the user collapsed.\n * A URL no entry claims is deliberately NOT recorded: see the effect below.\n */\n private lastAutoUrl: string | null = null;\n\n constructor() {\n // Seed `defaultOpen` groups — once per group, not once per `entries` emission.\n // Re-seeding meant any later change to the array (a badge count, a filtered\n // list) re-opened a group the user had deliberately collapsed.\n effect(() => {\n const fresh: string[] = [];\n for (const entry of this.entries()) {\n if (isGroup(entry) && entry.defaultOpen && !this.seeded.has(entry.title)) {\n this.seeded.add(entry.title);\n fresh.push(entry.title);\n }\n }\n if (fresh.length > 0) {\n this.opened.update(prev => new Set([...prev, ...fresh]));\n }\n });\n\n // Auto-expand the group containing the current URL.\n effect(() => {\n if (!this.autoExpand()) return;\n const url = this.currentUrl();\n if (url === this.lastAutoUrl) return;\n // Latch AFTER the lookup, never before it. `entries` often arrives from an\n // HTTP call, so the first run matches nothing — and latching first told the\n // re-run that landed with the real nav that this URL was already handled.\n // A deep link into a `defaultOpen: false` group then stayed collapsed, with\n // the active link out of the tab order, until the user navigated away and\n // back. Only a run that found a group has actually handled the URL; the\n // user-collapse latch is unaffected, since that run is also the one that\n // records it.\n const match = this.findGroupForUrl(url);\n if (!match) return;\n this.lastAutoUrl = url;\n if (this.opened().has(match)) return;\n this.opened.update(prev => {\n const next = new Set(prev);\n next.add(match);\n return next;\n });\n });\n }\n\n protected isOpen(title: string): boolean {\n return this.opened().has(title);\n }\n\n /**\n * Stable id for the group body, referenced by the toggle's `aria-controls`.\n *\n * Keyed by POSITION, not by the title. Slugging the title kept the id readable\n * but was not injective: it lowercases and collapses every non-`[a-z0-9]` run,\n * so `Data & charts` and `Data / charts` produced the same string, and a title\n * with no ASCII alphanumerics at all — `Компоненты`, `数据` — produced the empty\n * one. A whole Russian or Chinese sidebar therefore emitted N identical ids and\n * every toggle's `aria-controls` resolved to the FIRST group's body. The toggle\n * and its body read `$index` from the same `@for` iteration, so the two always\n * name each other — reordering `entries` renumbers both together.\n */\n protected groupBodyId(index: number): string {\n return `wr-sidebar-${this.uid}-group-${index}`;\n }\n\n protected toggleGroup(title: string): void {\n this.opened.update(prev => {\n const next = new Set(prev);\n if (next.has(title)) next.delete(title);\n else next.add(title);\n return next;\n });\n }\n\n protected asItem(entry: WrSidebarEntry): WrSidebarItem {\n return entry as WrSidebarItem;\n }\n\n protected asGroup(entry: WrSidebarEntry): WrSidebarGroup {\n return entry as WrSidebarGroup;\n }\n\n private findGroupForUrl(url: string): string | null {\n for (const entry of this.entries()) {\n if (!isGroup(entry)) continue;\n const hit = entry.children.some(child => matchesRoute(url, routePath(child.url)));\n if (hit) return entry.title;\n }\n return null;\n }\n}\n","<!-- A div, not a <nav>: the host already IS the navigation landmark, and two\n nested ones announce the same list twice. -->\n<div class=\"wr-sidebar__inner\">\n @for (entry of entries(); track entry.title) {\n @if (!isGroup(entry)) {\n <!--\n A span, not a greyed anchor, exactly as the nested branch below does it.\n `--disabled` only paints: `pointer-events: none` stops a real pointer but\n not the keyboard, so a disabled entry stayed in the tab order and Enter\n navigated — `disabled` on a `WrSidebarItem` is documented as \"no\n navigation\". An empty `url` is the same case: `[routerLink]=\"[]\"` resolves\n to the current route, which is a link that goes nowhere.\n -->\n @if (asItem(entry).disabled || !asItem(entry).url.length) {\n <span class=\"wr-sidebar__entry wr-sidebar__entry--disabled\">\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </span>\n } @else {\n <a\n class=\"wr-sidebar__entry\"\n routerLinkActive=\"wr-sidebar__entry--active\"\n [routerLink]=\"asItem(entry).url\"\n [routerLinkActiveOptions]=\"asItem(entry).activeOptions ?? defaultActiveOptions\"\n >\n @if (asItem(entry).icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n @if (asItem(entry).badge) {\n <small class=\"wr-sidebar__badge\">{{ asItem(entry).badge }}</small>\n }\n </a>\n }\n } @else {\n <section class=\"wr-sidebar__group\" [class.wr-sidebar__group--collapsed]=\"!isOpen(entry.title)\">\n <button\n type=\"button\"\n class=\"wr-sidebar__group-toggle\"\n [attr.aria-expanded]=\"isOpen(entry.title)\"\n [attr.aria-controls]=\"groupBodyId($index)\"\n (click)=\"toggleGroup(entry.title)\"\n >\n @if (asGroup(entry).icon || defaultGroupIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__title\">{{ entry.title }}</span>\n <!-- Inline chrome chevron — not user-customizable. -->\n <svg\n class=\"wr-icon__svg wr-sidebar__chevron\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n <div\n class=\"wr-sidebar__group-wrap\"\n [attr.id]=\"groupBodyId($index)\"\n [attr.inert]=\"!isOpen(entry.title) ? '' : null\"\n >\n <div class=\"wr-sidebar__group-body\">\n <ul class=\"wr-sidebar__list\">\n @for (child of asGroup(entry).children; track child.title) {\n <li>\n @if (child.disabled || !child.url.length) {\n <span class=\"wr-sidebar__item wr-sidebar__item--disabled\">\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </span>\n } @else {\n <a\n class=\"wr-sidebar__item\"\n routerLinkActive=\"wr-sidebar__item--active\"\n [routerLink]=\"child.url\"\n [routerLinkActiveOptions]=\"child.activeOptions ?? defaultActiveOptions\"\n >\n @if (child.icon || defaultItemIcon(); as name) {\n <wr-icon class=\"wr-sidebar__icon\" [name]=\"name\" />\n }\n <span class=\"wr-sidebar__item-title\">{{ child.title }}</span>\n @if (child.badge) {\n <small class=\"wr-sidebar__badge\">{{ child.badge }}</small>\n }\n </a>\n }\n </li>\n }\n </ul>\n </div>\n </div>\n </section>\n }\n }\n</div>\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
|
@@ -16,7 +16,7 @@ import { InjectionToken } from '@angular/core';
|
|
|
16
16
|
* Distinct from `Version` exported by `@angular/core`, which reports the
|
|
17
17
|
* loaded Angular runtime version (e.g. `'22.0.0'`), not ngwr's.
|
|
18
18
|
*/
|
|
19
|
-
const NGWR_VERSION = '14.
|
|
19
|
+
const NGWR_VERSION = '14.10.0';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* @license
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ngwr-version.mjs","sources":["../../../projects/lib/version/version.ts","../../../projects/lib/version/version.token.ts","../../../projects/lib/version/ngwr-version.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\n/**\n * The ngwr library version string, kept in sync with\n * `projects/lib/package.json` by `scripts/release-prepare.ts`.\n *\n * Prefer this over reading `package.json` at runtime — that requires a JSON\n * import or `require`, neither of which tree-shakes well in Angular builds.\n *\n * Distinct from `Version` exported by `@angular/core`, which reports the\n * loaded Angular runtime version (e.g. `'22.0.0'`), not ngwr's.\n */\nexport const NGWR_VERSION = '14.
|
|
1
|
+
{"version":3,"file":"ngwr-version.mjs","sources":["../../../projects/lib/version/version.ts","../../../projects/lib/version/version.token.ts","../../../projects/lib/version/ngwr-version.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\n/**\n * The ngwr library version string, kept in sync with\n * `projects/lib/package.json` by `scripts/release-prepare.ts`.\n *\n * Prefer this over reading `package.json` at runtime — that requires a JSON\n * import or `require`, neither of which tree-shakes well in Angular builds.\n *\n * Distinct from `Version` exported by `@angular/core`, which reports the\n * loaded Angular runtime version (e.g. `'22.0.0'`), not ngwr's.\n */\nexport const NGWR_VERSION = '14.10.0';\n","/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport { InjectionToken } from '@angular/core';\n\nimport { NGWR_VERSION } from './version';\n\n/**\n * Injection token holding the ngwr library version string.\n *\n * Default factory returns {@link NGWR_VERSION}. Override it in tests or in\n * apps that want to surface a custom build identifier.\n *\n * @example\n * ```ts\n * @Component({...})\n * export class FooterComponent {\n * readonly version = inject(NGWR_VERSION_TOKEN);\n * }\n * ```\n */\nexport const NGWR_VERSION_TOKEN = new InjectionToken<string>('NGWR_VERSION_TOKEN', {\n providedIn: 'root',\n factory: () => NGWR_VERSION,\n});\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":";;AAAA;;;;;AAKG;AAEH;;;;;;;;;AASG;AACI,MAAM,YAAY,GAAG;;ACjB5B;;;;;AAKG;AAMH;;;;;;;;;;;;;AAaG;MACU,kBAAkB,GAAG,IAAI,cAAc,CAAS,oBAAoB,EAAE;AACjF,IAAA,UAAU,EAAE,MAAM;AAClB,IAAA,OAAO,EAAE,MAAM,YAAY;AAC5B,CAAA;;AC5BD;;AAEG;;"}
|
package/package.json
CHANGED
|
@@ -76,10 +76,24 @@ interface WrEditorHarnessFilters extends BaseHarnessFilters {
|
|
|
76
76
|
* {@link getTool} hands back a `WrButtonHarness`; what that harness does not read
|
|
77
77
|
* (`aria-pressed`, `aria-keyshortcuts`, the roving tab stop) is answered here.
|
|
78
78
|
*
|
|
79
|
-
*
|
|
79
|
+
* **A read-only editor has no surface at all**, and that is the state to know
|
|
80
|
+
* about before reading anything here. `readonly` set before the editor mounts
|
|
81
|
+
* means ProseMirror is never created: the document is drawn as ordinary markup
|
|
82
|
+
* in a focusable `role="group"`, which takes over the id, the name and the tab
|
|
83
|
+
* stop. So the reads answer from whichever element is on screen —
|
|
84
|
+
* {@link getText}, {@link getLabel}, {@link isDisabled}, {@link isInvalid},
|
|
85
|
+
* {@link focus} — while everything that needs a live view ({@link setText},
|
|
86
|
+
* {@link paste}, {@link pressKey}, {@link selectAll}) throws a sentence saying
|
|
87
|
+
* there is nothing to type into rather than passing on a surface that is not
|
|
88
|
+
* there. {@link isMounted} is the question itself, and {@link isReadonly} keeps
|
|
89
|
+
* answering: the static document exists in that one state, which is how it is
|
|
90
|
+
* read, since ARIA allows no `aria-readonly` on a group.
|
|
91
|
+
*
|
|
92
|
+
* Otherwise every method reads the MOUNTED surface. ProseMirror takes it over in
|
|
80
93
|
* `afterNextRender`, which the harness's own stabilising lets run; under a server
|
|
81
|
-
* `PLATFORM_ID` it never does, and the
|
|
82
|
-
* something this harness pretends to
|
|
94
|
+
* `PLATFORM_ID` it never does, and the transient preview the server renders — the
|
|
95
|
+
* one a browser is about to replace — is not something this harness pretends to
|
|
96
|
+
* be the editor.
|
|
83
97
|
*
|
|
84
98
|
* **A spec under jsdom installs one stub first.** After an edit made while the
|
|
85
99
|
* surface has focus, ProseMirror scrolls the selection into view, which measures a
|
|
@@ -116,15 +130,22 @@ declare class WrEditorHarness extends ComponentHarness {
|
|
|
116
130
|
static hostSelector: string;
|
|
117
131
|
/** Build a predicate that narrows the query — pass to `getHarness` / `getAllHarnesses`. */
|
|
118
132
|
static with(options?: WrEditorHarnessFilters): HarnessPredicate<WrEditorHarness>;
|
|
119
|
-
|
|
133
|
+
/** The element carrying the editor's id, name and ARIA — the surface, or the static document. */
|
|
134
|
+
private readonly control;
|
|
135
|
+
private readonly liveSurface;
|
|
136
|
+
private readonly staticDoc;
|
|
120
137
|
/**
|
|
121
|
-
* Whether ProseMirror owns
|
|
138
|
+
* Whether ProseMirror owns a surface yet — read from the `contenteditable` it
|
|
122
139
|
* writes there, which nothing else does.
|
|
123
140
|
*
|
|
124
|
-
* `true` in any browser-platform spec
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* were the
|
|
141
|
+
* `true` in any browser-platform spec on an editor that is not read-only, by the
|
|
142
|
+
* time a harness method runs. `false` in the two states where no mount is coming:
|
|
143
|
+
* under a server `PLATFORM_ID`, where what shows is the transient preview and
|
|
144
|
+
* every other method here throws rather than answer about it as if it were the
|
|
145
|
+
* editor; and on a READ-ONLY editor, which never mounts by design and whose
|
|
146
|
+
* static document is read here like any other — this is the method that tells
|
|
147
|
+
* those two apart from a mounted one, and {@link isReadonly} tells them apart
|
|
148
|
+
* from each other.
|
|
128
149
|
*/
|
|
129
150
|
isMounted(): Promise<boolean>;
|
|
130
151
|
/**
|
|
@@ -140,11 +161,16 @@ declare class WrEditorHarness extends ComponentHarness {
|
|
|
140
161
|
*
|
|
141
162
|
* This is the drawn half of the two questions an editor answers; the class docs say
|
|
142
163
|
* why the other half, the value, is read from the model instead.
|
|
164
|
+
*
|
|
165
|
+
* A READ-ONLY editor draws its document without ProseMirror, and this reads that
|
|
166
|
+
* document — it is the editor there, not a stand-in for one.
|
|
143
167
|
*/
|
|
144
168
|
getText(): Promise<string>;
|
|
145
169
|
/**
|
|
146
|
-
* The
|
|
147
|
-
* surrounding `<wr-form-field>`'s label, else the catalog's `editor.label`.
|
|
170
|
+
* The editor's accessible name — `ariaLabel` when one is bound, else the
|
|
171
|
+
* surrounding `<wr-form-field>`'s label, else the catalog's `editor.label`. Read
|
|
172
|
+
* off whichever element carries it: the surface, or a read-only editor's static
|
|
173
|
+
* document, which takes the name over with the tab stop.
|
|
148
174
|
*
|
|
149
175
|
* A field's label reaches the surface through `aria-labelledby`, never `<label for>`:
|
|
150
176
|
* a contenteditable is not labelable. So the reference is resolved the way assistive
|
|
@@ -161,25 +187,38 @@ declare class WrEditorHarness extends ComponentHarness {
|
|
|
161
187
|
* `placeholder` is bound: the component publishes it only while the document is
|
|
162
188
|
* empty, the moment it is also drawn. A document holding an empty heading still
|
|
163
189
|
* counts as empty.
|
|
190
|
+
*
|
|
191
|
+
* Always `null` on a READ-ONLY editor, however `placeholder` is bound, and that is
|
|
192
|
+
* the truth rather than a hole: there is no input to hint at, and
|
|
193
|
+
* `aria-placeholder` is not among the attributes the static document's
|
|
194
|
+
* `role="group"` allows, so the component draws none there either.
|
|
164
195
|
*/
|
|
165
196
|
getPlaceholder(): Promise<string | null>;
|
|
166
197
|
/**
|
|
167
|
-
* Whether the editor is disabled — the surface
|
|
198
|
+
* Whether the editor is disabled — `aria-disabled`, on the surface or on the static
|
|
199
|
+
* document, whichever is drawn.
|
|
168
200
|
*
|
|
169
|
-
* Read from
|
|
201
|
+
* Read from there rather than from the `wr-editor--disabled` host class, because a
|
|
170
202
|
* contenteditable has no `disabled` property: the attribute is what a screen reader
|
|
171
203
|
* is told, and ProseMirror's own `contenteditable="false"` is what turns the
|
|
172
204
|
* keyboard away. The component sets both.
|
|
173
205
|
*/
|
|
174
206
|
isDisabled(): Promise<boolean>;
|
|
175
207
|
/**
|
|
176
|
-
* Whether the editor shows its document and refuses edits
|
|
177
|
-
*
|
|
178
|
-
*
|
|
208
|
+
* Whether the editor shows its document and refuses edits. Read-only keeps the tab
|
|
209
|
+
* stop, so {@link focus} still lands; disabled does not.
|
|
210
|
+
*
|
|
211
|
+
* Two DOM facts answer it, because the two shapes of read-only look nothing alike.
|
|
212
|
+
* An editor that was read-only before it could mount has no surface at all, and the
|
|
213
|
+
* static document it draws instead is drawn in that state and in no other — so its
|
|
214
|
+
* presence is the answer, and it is the only one available: ARIA defines no
|
|
215
|
+
* `aria-readonly` for `role="group"`, and `aria-disabled` would say something else.
|
|
216
|
+
* One that mounted first keeps its surface and reports `aria-readonly` there, which
|
|
217
|
+
* `role="textbox"` does allow.
|
|
179
218
|
*/
|
|
180
219
|
isReadonly(): Promise<boolean>;
|
|
181
220
|
/**
|
|
182
|
-
* Whether the
|
|
221
|
+
* Whether the editor is announced invalid — the `aria-invalid` the surrounding
|
|
183
222
|
* `<wr-form-field>` hands it when a bound field fails validation and has been
|
|
184
223
|
* touched. An editor outside a field is never invalid.
|
|
185
224
|
*/
|
|
@@ -198,18 +237,20 @@ declare class WrEditorHarness extends ComponentHarness {
|
|
|
198
237
|
* An empty `text` empties the document, which writes `''` (`null` in `json` format).
|
|
199
238
|
*
|
|
200
239
|
* Throws on a disabled or read-only editor, where no user could change a word — the
|
|
201
|
-
* surface is not contenteditable then, and
|
|
202
|
-
* a green spec for something that
|
|
240
|
+
* surface is not contenteditable then, and is not even rendered when read-only came
|
|
241
|
+
* first, so a write the DOM accepted anyway would be a green spec for something that
|
|
242
|
+
* cannot happen.
|
|
203
243
|
*/
|
|
204
244
|
setText(text: string): Promise<void>;
|
|
205
245
|
/**
|
|
206
246
|
* Paste `text` as plain text at the selection, replacing whatever is selected — a
|
|
207
247
|
* `paste` event carrying `text/plain`, handled by ProseMirror's own paste path.
|
|
208
248
|
*
|
|
209
|
-
* On
|
|
210
|
-
* the refusal is the editor's to make, and
|
|
211
|
-
*
|
|
212
|
-
* a paste to arrive at
|
|
249
|
+
* On an editor that was MOUNTED before it went read-only the event is still
|
|
250
|
+
* delivered, because a browser delivers it: the refusal is the editor's to make, and
|
|
251
|
+
* asserting the value did not change is how a spec proves it did. One that was
|
|
252
|
+
* read-only from the start has no surface for a paste to arrive at, and throws
|
|
253
|
+
* saying so. Throws on a disabled one too, whose surface cannot hold focus.
|
|
213
254
|
*/
|
|
214
255
|
paste(text: string): Promise<void>;
|
|
215
256
|
/**
|
|
@@ -230,7 +271,8 @@ declare class WrEditorHarness extends ComponentHarness {
|
|
|
230
271
|
* Throws on a read-only or disabled editor: ProseMirror hands the keyboard back to
|
|
231
272
|
* the browser once the surface is not editable, and the browser's native select-all
|
|
232
273
|
* is exactly what jsdom does not have, so the chord would select nothing and say so
|
|
233
|
-
* to no one
|
|
274
|
+
* to no one — and an editor that was read-only before it could mount has no surface
|
|
275
|
+
* to aim the chord at in the first place.
|
|
234
276
|
*/
|
|
235
277
|
selectAll(): Promise<void>;
|
|
236
278
|
/**
|
|
@@ -249,22 +291,22 @@ declare class WrEditorHarness extends ComponentHarness {
|
|
|
249
291
|
*/
|
|
250
292
|
pressKey(key: TestKey | string, modifiers?: ModifierKeys): Promise<void>;
|
|
251
293
|
/**
|
|
252
|
-
* Move focus to the text surface
|
|
253
|
-
*
|
|
294
|
+
* Move focus to the text — the mounted surface, or a read-only editor's static
|
|
295
|
+
* document, which keeps a tab stop for exactly this.
|
|
254
296
|
*
|
|
255
|
-
* Throws on a disabled
|
|
256
|
-
* take focus — no `tabindex`, and `contenteditable="false"` — but jsdom
|
|
257
|
-
* `contenteditable` attribute as focusable, `false` included, so the
|
|
258
|
-
* land in the spec and never in the app.
|
|
297
|
+
* Throws on a disabled editor rather than focusing it. In a browser its surface
|
|
298
|
+
* cannot take focus — no `tabindex`, and `contenteditable="false"` — but jsdom
|
|
299
|
+
* counts ANY `contenteditable` attribute as focusable, `false` included, so the
|
|
300
|
+
* focus would land in the spec and never in the app.
|
|
259
301
|
*/
|
|
260
302
|
focus(): Promise<void>;
|
|
261
303
|
/**
|
|
262
|
-
* Take focus away from the
|
|
304
|
+
* Take focus away from the text — the whole control losing focus, which is what
|
|
263
305
|
* emits `touch` and lets a bound field show its error. Focus moving to the editor's
|
|
264
306
|
* own toolbar or link panel is not a blur of the control, and does not emit it.
|
|
265
307
|
*/
|
|
266
308
|
blur(): Promise<void>;
|
|
267
|
-
/** Whether the text
|
|
309
|
+
/** Whether the text holds focus. A toolbar button holding it is not the text. */
|
|
268
310
|
isFocused(): Promise<boolean>;
|
|
269
311
|
/** Whether a toolbar is drawn — gone with `toolbar="false"`, and with a list holding no tool. */
|
|
270
312
|
hasToolbar(): Promise<boolean>;
|
|
@@ -363,10 +405,14 @@ declare class WrEditorHarness extends ComponentHarness {
|
|
|
363
405
|
* would read as "the address was accepted".
|
|
364
406
|
*/
|
|
365
407
|
getLinkError(): Promise<string | null>;
|
|
366
|
-
/** The
|
|
408
|
+
/** The element drawing the editor's own document — the mounted surface, or the static one. */
|
|
409
|
+
private drawnDocument;
|
|
410
|
+
/** The surface, or a sentence saying what is on screen instead of one. */
|
|
367
411
|
private mountedSurface;
|
|
368
412
|
/** The surface, when a user could still reach it with the keyboard. */
|
|
369
413
|
private focusableSurface;
|
|
414
|
+
/** Whatever a keyboard user could put focus on: the surface, or the static document. */
|
|
415
|
+
private focusableControl;
|
|
370
416
|
/** The surface, when a user could change its text. */
|
|
371
417
|
private writableSurface;
|
|
372
418
|
private dispatchPaste;
|
package/types/ngwr-editor.d.ts
CHANGED
|
@@ -161,6 +161,16 @@ interface WrToolState {
|
|
|
161
161
|
* editor is read-only and carries `wr-editor--refused`: the model still holds the
|
|
162
162
|
* refused value, and an edit would write the document on screen over it.
|
|
163
163
|
*
|
|
164
|
+
* **`readonly` never mounts ProseMirror.** The document is drawn as ordinary
|
|
165
|
+
* Angular markup instead — the same rendering the server prerenders — so a page
|
|
166
|
+
* showing thirty saved comments boots no editor at all, and the headings, lists
|
|
167
|
+
* and tables of what it shows stay in the accessibility tree rather than being
|
|
168
|
+
* swallowed by a textbox, every descendant of which is presentational. It is
|
|
169
|
+
* lazy, not permanent: the first time `readonly` is lifted the editor mounts,
|
|
170
|
+
* and from there behaves exactly as one that never was read-only. Setting it
|
|
171
|
+
* again keeps the mounted view — throwing a live session's undo history away to
|
|
172
|
+
* save one view would cost more than the view.
|
|
173
|
+
*
|
|
164
174
|
* Headings stop at three levels: an h4–h6 read from HTML or markdown becomes an
|
|
165
175
|
* h3 rather than a paragraph. Underline exists in `html` and `json` only —
|
|
166
176
|
* markdown cannot spell it, so in that format the tool, the shortcut and the mark
|
|
@@ -198,13 +208,22 @@ declare class WrEditor implements FormValueControl<WrEditorValue> {
|
|
|
198
208
|
*/
|
|
199
209
|
readonly disabled: _angular_core.InputSignalWithTransform<boolean, any>;
|
|
200
210
|
/**
|
|
201
|
-
* Refuse edits while the text stays
|
|
211
|
+
* Refuse edits while the text stays reachable, selectable and announced.
|
|
202
212
|
* Bound automatically from the field's readonly state when used with
|
|
203
213
|
* `[formField]`.
|
|
204
214
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
215
|
+
* Set before the editor mounts, it means ProseMirror is never started: the
|
|
216
|
+
* document is drawn as ordinary markup in a focusable `role="group"` that
|
|
217
|
+
* keeps the tab stop and carries the editor's name, so a viewer costs no
|
|
218
|
+
* editor and the structure of what it shows is still announced. `aria-readonly`
|
|
219
|
+
* is deliberately absent there — ARIA does not allow it on `group`, and
|
|
220
|
+
* `aria-disabled`, the only attribute that would apply, means something else —
|
|
221
|
+
* so the cue is the `wr-editor--readonly` class. The toolbar goes inert, since
|
|
222
|
+
* none of its tools can apply; a pure viewer binds `[toolbar]="false"`.
|
|
223
|
+
*
|
|
224
|
+
* A view that is already mounted stays mounted, refuses typing, paste, drop,
|
|
225
|
+
* the shortcuts and every toolbar command, and reports `aria-readonly` on its
|
|
226
|
+
* surface as before.
|
|
208
227
|
*
|
|
209
228
|
* @default false
|
|
210
229
|
*/
|
|
@@ -216,7 +235,8 @@ declare class WrEditor implements FormValueControl<WrEditorValue> {
|
|
|
216
235
|
*/
|
|
217
236
|
readonly placeholder: _angular_core.InputSignal<string>;
|
|
218
237
|
/**
|
|
219
|
-
* Accessible name of the text surface
|
|
238
|
+
* Accessible name of the text — of the surface, or of the document a
|
|
239
|
+
* read-only editor draws in its place. Falls back to the surrounding
|
|
220
240
|
* `<wr-form-field>`'s label, then to `editor.label`.
|
|
221
241
|
*/
|
|
222
242
|
readonly ariaLabel: _angular_core.InputSignal<string | null>;
|
|
@@ -263,6 +283,12 @@ declare class WrEditor implements FormValueControl<WrEditorValue> {
|
|
|
263
283
|
protected readonly surfaceId: string;
|
|
264
284
|
/** ProseMirror owns the surface from here on; until then the preview stands in. */
|
|
265
285
|
protected readonly mounted: _angular_core.WritableSignal<boolean>;
|
|
286
|
+
/**
|
|
287
|
+
* Read-only and nothing mounted: the preview is not standing in for a surface,
|
|
288
|
+
* it IS the editor, and the surface is not rendered at all. One id, one name
|
|
289
|
+
* and one tab stop move onto it with the job. @internal
|
|
290
|
+
*/
|
|
291
|
+
protected readonly staticMode: Signal<boolean>;
|
|
266
292
|
protected readonly focused: _angular_core.WritableSignal<boolean>;
|
|
267
293
|
private readonly editorState;
|
|
268
294
|
/**
|
|
@@ -278,6 +304,7 @@ declare class WrEditor implements FormValueControl<WrEditorValue> {
|
|
|
278
304
|
/** Apple keyboards name the modifier ⌘. Read in the browser only — see `mount()`. */
|
|
279
305
|
private readonly apple;
|
|
280
306
|
private readonly surface;
|
|
307
|
+
private readonly staticDoc;
|
|
281
308
|
private readonly toolButtons;
|
|
282
309
|
private readonly linkPopover;
|
|
283
310
|
private view;
|
|
@@ -291,6 +318,12 @@ declare class WrEditor implements FormValueControl<WrEditorValue> {
|
|
|
291
318
|
*/
|
|
292
319
|
private lastSerialized;
|
|
293
320
|
private linkToSurface;
|
|
321
|
+
/**
|
|
322
|
+
* Whether the static path has already named the refusal the model is holding.
|
|
323
|
+
* A read-only editor reports one itself, and a mount that follows would
|
|
324
|
+
* otherwise warn a second time about the same value.
|
|
325
|
+
*/
|
|
326
|
+
private refusalWarned;
|
|
294
327
|
protected readonly toolbarLabel: Signal<string>;
|
|
295
328
|
protected readonly linkUrlLabel: Signal<string>;
|
|
296
329
|
protected readonly linkApplyLabel: Signal<string>;
|
|
@@ -329,19 +362,29 @@ declare class WrEditor implements FormValueControl<WrEditorValue> {
|
|
|
329
362
|
private readonly empty;
|
|
330
363
|
/** The task-item words, handed to ProseMirror through its node views. */
|
|
331
364
|
private readonly nodeViews;
|
|
365
|
+
/**
|
|
366
|
+
* Nothing in the static mode: a hint for input, where no input is taken, and
|
|
367
|
+
* `aria-placeholder` is not among the attributes `role="group"` allows — so
|
|
368
|
+
* one drawn there would be text no screen reader ever reaches.
|
|
369
|
+
*/
|
|
332
370
|
protected readonly ariaPlaceholder: Signal<string | null>;
|
|
333
371
|
protected readonly linkDraft: _angular_core.WritableSignal<string>;
|
|
334
372
|
/** Whether the selection is in a link — what the panel's remove button needs. */
|
|
335
373
|
protected readonly linkActive: Signal<boolean>;
|
|
336
374
|
/**
|
|
337
|
-
* The value rendered by Angular
|
|
338
|
-
*
|
|
339
|
-
*
|
|
375
|
+
* The value rendered by Angular rather than by ProseMirror: on the server, for
|
|
376
|
+
* the first browser frame, and — for as long as it lasts — in the static mode,
|
|
377
|
+
* where no mount is coming. Read from the doc JSON, so it shows exactly what
|
|
378
|
+
* the schema kept: the same sanitised document the editor would mount.
|
|
340
379
|
*/
|
|
341
380
|
protected readonly preview: Signal<WrEditorJson | null>;
|
|
342
381
|
protected readonly classes: Signal<string>;
|
|
343
382
|
constructor();
|
|
344
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* Focus the editor — what Signal Forms calls to focus this control. Whatever
|
|
385
|
+
* is on screen takes it: the mounted surface, or the static document, which
|
|
386
|
+
* keeps a tab stop for exactly this reason.
|
|
387
|
+
*/
|
|
345
388
|
focus(options?: FocusOptions): void;
|
|
346
389
|
protected label(tool: WrEditorCommandTool): string;
|
|
347
390
|
protected icon(tool: WrEditorCommandTool): readonly string[];
|
|
@@ -396,6 +439,17 @@ declare class WrEditor implements FormValueControl<WrEditorValue> {
|
|
|
396
439
|
* (IME) replaces the composition too: the model is the source of truth.
|
|
397
440
|
*/
|
|
398
441
|
private sync;
|
|
442
|
+
/**
|
|
443
|
+
* The check `mount()` makes, made without a mount: the same warning and the
|
|
444
|
+
* same `refused` flag, so an unreadable value is refused whether or not
|
|
445
|
+
* ProseMirror ever starts. Without it a read-only editor met one with silence
|
|
446
|
+
* — `preview()` renders nothing for a document it cannot read, and the model
|
|
447
|
+
* goes on holding a value nothing has said a word about.
|
|
448
|
+
*
|
|
449
|
+
* The parsed document is dropped; `preview()` reads its own. The cost is one
|
|
450
|
+
* extra parse per write, and only while read-only, where a write is rare.
|
|
451
|
+
*/
|
|
452
|
+
private readStatically;
|
|
399
453
|
/**
|
|
400
454
|
* A new format whose value could not be read: keep the document, moved into
|
|
401
455
|
* the new format's schema — markdown's lacks underline, so that mark is
|
package/types/ngwr-sidebar.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as _angular_core from '@angular/core';
|
|
2
|
+
import { IsActiveMatchOptions } from '@angular/router';
|
|
2
3
|
import { WrIconName } from 'ngwr/icon';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -8,6 +9,13 @@ import { WrIconName } from 'ngwr/icon';
|
|
|
8
9
|
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* What `[routerLinkActiveOptions]` accepts: the short form, or any subset of the
|
|
14
|
+
* router's own match options (`paths`, `queryParams`, `matrixParams`, `fragment`).
|
|
15
|
+
*/
|
|
16
|
+
type WrSidebarActiveOptions = {
|
|
17
|
+
readonly exact: boolean;
|
|
18
|
+
} | Partial<IsActiveMatchOptions>;
|
|
11
19
|
/** A single navigable entry. */
|
|
12
20
|
interface WrSidebarItem {
|
|
13
21
|
readonly title: string;
|
|
@@ -18,6 +26,16 @@ interface WrSidebarItem {
|
|
|
18
26
|
readonly badge?: string;
|
|
19
27
|
/** Render as disabled (no navigation). */
|
|
20
28
|
readonly disabled?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* How the router decides this entry is active — the value of
|
|
31
|
+
* `[routerLinkActiveOptions]`. The default matches a path PREFIX, so an entry at
|
|
32
|
+
* `['/']` is active on every route; `{ exact: true }` is what a "Home" entry
|
|
33
|
+
* wants. It decides the active class only: `autoExpand` finds the group holding
|
|
34
|
+
* the current route by the same prefix rule either way.
|
|
35
|
+
*
|
|
36
|
+
* @default { exact: false }
|
|
37
|
+
*/
|
|
38
|
+
readonly activeOptions?: WrSidebarActiveOptions;
|
|
21
39
|
}
|
|
22
40
|
/** Expandable group of items. */
|
|
23
41
|
interface WrSidebarGroup {
|
|
@@ -76,6 +94,13 @@ declare class WrSidebar {
|
|
|
76
94
|
/** Auto-expand the group containing the active route. @default true */
|
|
77
95
|
readonly autoExpand: _angular_core.InputSignalWithTransform<boolean, any>;
|
|
78
96
|
protected readonly isGroup: typeof isGroup;
|
|
97
|
+
/**
|
|
98
|
+
* What an entry without its own `activeOptions` gets. It is the router's own
|
|
99
|
+
* default, written out because a binding cannot be left unset per entry.
|
|
100
|
+
*/
|
|
101
|
+
protected readonly defaultActiveOptions: {
|
|
102
|
+
exact: boolean;
|
|
103
|
+
};
|
|
79
104
|
private readonly opened;
|
|
80
105
|
/** Group titles already seeded from `defaultOpen`, so it seeds once and not again. */
|
|
81
106
|
private readonly seeded;
|
package/types/ngwr-version.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { InjectionToken } from '@angular/core';
|
|
|
16
16
|
* Distinct from `Version` exported by `@angular/core`, which reports the
|
|
17
17
|
* loaded Angular runtime version (e.g. `'22.0.0'`), not ngwr's.
|
|
18
18
|
*/
|
|
19
|
-
declare const NGWR_VERSION = "14.
|
|
19
|
+
declare const NGWR_VERSION = "14.10.0";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* @license
|