diff --git a/packages/demo/src/app-routing.ts b/packages/demo/src/app-routing.ts index 94e1fe2dc..7e494196a 100644 --- a/packages/demo/src/app-routing.ts +++ b/packages/demo/src/app-routing.ts @@ -1,180 +1,325 @@ -import Events from './events/events.js'; -import Example01 from './examples/example01.js'; -import Example02 from './examples/example02.js'; -import Example03 from './examples/example03.js'; -import Example04 from './examples/example04.js'; -import Example05 from './examples/example05.js'; -import Example06 from './examples/example06.js'; -import Example07 from './examples/example07.js'; -import Example08 from './examples/example08.js'; -import Example09 from './examples/example09.js'; -import Example10 from './examples/example10.js'; -import Example11 from './examples/example11.js'; -import Example12 from './examples/example12.js'; -import Example13 from './examples/example13.js'; -import Example14 from './examples/example14.js'; -import Example15 from './examples/example15.js'; -import Example16 from './examples/example16.js'; -import GettingStarted from './getting-started.js'; -import I18n from './i18n/i18n.js'; -import Methods01 from './methods/methods01.js'; -import Methods02 from './methods/methods02.js'; -import Methods03 from './methods/methods03.js'; -import Methods04 from './methods/methods04.js'; -import Methods05 from './methods/methods05.js'; -import Methods06 from './methods/methods06.js'; -import Methods07 from './methods/methods07.js'; -import Methods08 from './methods/methods08.js'; -import Methods09 from './methods/methods09.js'; -import Methods10 from './methods/methods10.js'; -import Methods11 from './methods/methods11.js'; -import Methods12 from './methods/methods12.js'; -import Methods13 from './methods/methods13.js'; -import Options01 from './options/options01.js'; -import Options02 from './options/options02.js'; -import Options03 from './options/options03.js'; -import Options04 from './options/options04.js'; -import Options05 from './options/options05.js'; -import Options06 from './options/options06.js'; -import Options07 from './options/options07.js'; -import Options08 from './options/options08.js'; -import Options09 from './options/options09.js'; -import Options10 from './options/options10.js'; -import Options11 from './options/options11.js'; -import Options12 from './options/options12.js'; -import Options13 from './options/options13.js'; -import Options14 from './options/options14.js'; -import Options15 from './options/options15.js'; -import Options16 from './options/options16.js'; -import Options17 from './options/options17.js'; -import Options18 from './options/options18.js'; -import Options19 from './options/options19.js'; -import Options20 from './options/options20.js'; -import Options21 from './options/options21.js'; -import Options22 from './options/options22.js'; -import Options23 from './options/options23.js'; -import Options24 from './options/options24.js'; -import Options25 from './options/options25.js'; -import Options26 from './options/options26.js'; -import Options27 from './options/options27.js'; -import Options28 from './options/options28.js'; -import Options29 from './options/options29.js'; -import Options30 from './options/options30.js'; -import Options31 from './options/options31.js'; -import Options32 from './options/options32.js'; -import Options33 from './options/options33.js'; -import Options34 from './options/options34.js'; -import Options35 from './options/options35.js'; -import Options36 from './options/options36.js'; -import Options37 from './options/options37.js'; -import Options38 from './options/options38.js'; -import Options39 from './options/options39.js'; -import Options40 from './options/options40.js'; -import Options41 from './options/options41.js'; -import Options42 from './options/options42.js'; -import Options43 from './options/options43.js'; -import Options44 from './options/options44.js'; +import type { RouteGroup, ViewRouter } from './interfaces.js'; -export const navbarRouting = [ - { name: 'getting-started', view: '/src/getting-started.html', viewModel: GettingStarted, title: 'Getting Started' }, - { name: 'examples', view: '/src/examples/example01.html', viewModel: Example01, title: 'Examples' }, +export const navbarRouting: ViewRouter[] = [ + { name: 'getting-started', view: '/src/getting-started.html', viewModel: () => import('./getting-started.js'), title: 'Getting Started' }, + { name: 'examples', view: '/src/examples/example01.html', viewModel: () => import('./examples/example01.js'), title: 'Examples' }, ]; -export const exampleRouting = [ +export const exampleRouting: RouteGroup[] = [ { name: 'Welcomes', routes: [ - { name: 'example01', view: '/src/examples/example01.html', viewModel: Example01, title: 'Single Select' }, - { name: 'example02', view: '/src/examples/example02.html', viewModel: Example02, title: 'Multiple Select' }, - { name: 'example03', view: '/src/examples/example03.html', viewModel: Example03, title: 'Multiple Items' }, - { name: 'example04', view: '/src/examples/example04.html', viewModel: Example04, title: 'Auto Width' }, - { name: 'example05', view: '/src/examples/example05.html', viewModel: Example05, title: 'Disabled Select' }, - { name: 'example06', view: '/src/examples/example06.html', viewModel: Example06, title: 'Selected/Disabled Options' }, - { name: 'example07', view: '/src/examples/example07.html', viewModel: Example07, title: 'Submit Select' }, - { name: 'example08', view: '/src/examples/example08.html', viewModel: Example08, title: 'The Data' }, - { name: 'example09', view: '/src/examples/example09.html', viewModel: Example09, title: 'Locale' }, - { name: 'example10', view: '/src/examples/example10.html', viewModel: Example10, title: 'Large Select (Virtual Scroll)' }, - { name: 'example11', view: '/src/examples/example11.html', viewModel: Example11, title: 'Themes / Sizes' }, - { name: 'example12', view: '/src/examples/example12.html', viewModel: Example12, title: 'Checkbox/Radio Icons' }, - { name: 'example13', view: '/src/examples/example13.html', viewModel: Example13, title: 'Dynamically Create Select' }, - { name: 'example14', view: '/src/examples/example14.html', viewModel: Example14, title: 'The Divider' }, - { name: 'example15', view: '/src/examples/example15.html', viewModel: Example15, title: 'Dark Mode' }, - { name: 'example16', view: '/src/examples/example16.html', viewModel: Example16, title: 'Template with Images' }, + { + name: 'example01', + view: '/src/examples/example01.html', + viewModel: () => import('./examples/example01.js'), + title: 'Single Select', + }, + { + name: 'example02', + view: '/src/examples/example02.html', + viewModel: () => import('./examples/example02.js'), + title: 'Multiple Select', + }, + { + name: 'example03', + view: '/src/examples/example03.html', + viewModel: () => import('./examples/example03.js'), + title: 'Multiple Items', + }, + { name: 'example04', view: '/src/examples/example04.html', viewModel: () => import('./examples/example04.js'), title: 'Auto Width' }, + { + name: 'example05', + view: '/src/examples/example05.html', + viewModel: () => import('./examples/example05.js'), + title: 'Disabled Select', + }, + { + name: 'example06', + view: '/src/examples/example06.html', + viewModel: () => import('./examples/example06.js'), + title: 'Selected/Disabled Options', + }, + { + name: 'example07', + view: '/src/examples/example07.html', + viewModel: () => import('./examples/example07.js'), + title: 'Submit Select', + }, + { name: 'example08', view: '/src/examples/example08.html', viewModel: () => import('./examples/example08.js'), title: 'The Data' }, + { name: 'example09', view: '/src/examples/example09.html', viewModel: () => import('./examples/example09.js'), title: 'Locale' }, + { + name: 'example10', + view: '/src/examples/example10.html', + viewModel: () => import('./examples/example10.js'), + title: 'Large Select (Virtual Scroll)', + }, + { + name: 'example11', + view: '/src/examples/example11.html', + viewModel: () => import('./examples/example11.js'), + title: 'Themes / Sizes', + }, + { + name: 'example12', + view: '/src/examples/example12.html', + viewModel: () => import('./examples/example12.js'), + title: 'Checkbox/Radio Icons', + }, + { + name: 'example13', + view: '/src/examples/example13.html', + viewModel: () => import('./examples/example13.js'), + title: 'Dynamically Create Select', + }, + { name: 'example14', view: '/src/examples/example14.html', viewModel: () => import('./examples/example14.js'), title: 'The Divider' }, + { name: 'example15', view: '/src/examples/example15.html', viewModel: () => import('./examples/example15.js'), title: 'Dark Mode' }, + { + name: 'example16', + view: '/src/examples/example16.html', + viewModel: () => import('./examples/example16.js'), + title: 'Template with Images', + }, ], }, { name: 'Options', routes: [ - { name: 'options01', view: '/src/options/options01.html', viewModel: Options01, title: 'The Placeholder' }, - { name: 'options02', view: '/src/options/options02.html', viewModel: Options02, title: 'Single Radio' }, - { name: 'options03', view: '/src/options/options03.html', viewModel: Options03, title: 'Hide Select All' }, - { name: 'options04', view: '/src/options/options04.html', viewModel: Options04, title: 'Hide Optgroup Checkboxes' }, - { name: 'options05', view: '/src/options/options05.html', viewModel: Options05, title: 'Custom Dropdown Width' }, - { name: 'options06', view: '/src/options/options06.html', viewModel: Options06, title: 'Max Height' }, - { name: 'options07', view: '/src/options/options07.html', viewModel: Options07, title: 'Max Height Unit' }, - { name: 'options08', view: '/src/options/options08.html', viewModel: Options08, title: 'The Position' }, - { name: 'options09', view: '/src/options/options09.html', viewModel: Options09, title: 'Display Values' }, - { name: 'options10', view: '/src/options/options10.html', viewModel: Options10, title: 'Display Title' }, - { name: 'options11', view: '/src/options/options11.html', viewModel: Options11, title: 'Display Delimiter' }, - { name: 'options12', view: '/src/options/options12.html', viewModel: Options12, title: 'Minimum Count Selected' }, - { name: 'options13', view: '/src/options/options13.html', viewModel: Options13, title: 'The Ellipsis' }, - { name: 'options14', view: '/src/options/options14.html', viewModel: Options14, title: 'Is Open' }, - { name: 'options15', view: '/src/options/options15.html', viewModel: Options15, title: 'Keep Open' }, - { name: 'options16', view: '/src/options/options16.html', viewModel: Options16, title: 'Open On Hover' }, - { name: 'options17', view: '/src/options/options17.html', viewModel: Options17, title: 'The Container' }, - { name: 'options18', view: '/src/options/options18.html', viewModel: Options18, title: 'The Filter' }, - { name: 'options19', view: '/src/options/options19.html', viewModel: Options19, title: 'Filter Only Optgroup' }, - { name: 'options20', view: '/src/options/options20.html', viewModel: Options20, title: 'Filter Placeholder' }, - { name: 'options21', view: '/src/options/options21.html', viewModel: Options21, title: 'Filter Accept On Enter' }, - { name: 'options22', view: '/src/options/options22.html', viewModel: Options22, title: 'Filter By Data Length' }, - { name: 'options23', view: '/src/options/options23.html', viewModel: Options23, title: 'Custom Filter' }, - { name: 'options24', view: '/src/options/options24.html', viewModel: Options24, title: 'Show Clear' }, - { name: 'options25', view: '/src/options/options25.html', viewModel: Options25, title: 'Show OK Button' }, - { name: 'options26', view: '/src/options/options26.html', viewModel: Options26, title: 'The Styler' }, - { name: 'options27', view: '/src/options/options27.html', viewModel: Options27, title: 'Text Template' }, - { name: 'options28', view: '/src/options/options28.html', viewModel: Options28, title: 'Label Template' }, - { name: 'options29', view: '/src/options/options29.html', viewModel: Options29, title: 'Auto-Adjust Drop Position' }, - { name: 'options30', view: '/src/options/options30.html', viewModel: Options30, title: 'Auto-Adjust Drop Height/Width' }, - { name: 'options31', view: '/src/options/options31.html', viewModel: Options31, title: 'Use Select Option as Label' }, - { name: 'options32', view: '/src/options/options32.html', viewModel: Options32, title: 'Sanitizer' }, - { name: 'options33', view: '/src/options/options33.html', viewModel: Options33, title: 'Classes' }, - { name: 'options34', view: '/src/options/options34.html', viewModel: Options34, title: 'Show Search Clear' }, - { name: 'options35', view: '/src/options/options35.html', viewModel: Options35, title: 'Custom Diacritic Parser' }, - { name: 'options36', view: '/src/options/options36.html', viewModel: Options36, title: 'Infinite Scroll' }, - { name: 'options37', view: '/src/options/options37.html', viewModel: Options37, title: 'Navigation Highlight' }, - { name: 'options38', view: '/src/options/options38.html', viewModel: Options38, title: 'Dark Mode' }, - { name: 'options39', view: '/src/options/options39.html', viewModel: Options39, title: 'Label Id (aria-labelledby)' }, - { name: 'options40', view: '/src/options/options40.html', viewModel: Options40, title: 'Pre-Filter Data' }, - { name: 'options41', view: '/src/options/options41.html', viewModel: Options41, title: 'Pre-Sort Data' }, - { name: 'options42', view: '/src/options/options42.html', viewModel: Options42, title: 'Lazy Load Data' }, - { name: 'options43', view: '/src/options/options43.html', viewModel: Options43, title: 'Close on Tab' }, - { name: 'options44', view: '/src/options/options44.html', viewModel: Options44, title: 'tabIndex' }, + { + name: 'options01', + view: '/src/options/options01.html', + viewModel: () => import('./options/options01.js'), + title: 'The Placeholder', + }, + { name: 'options02', view: '/src/options/options02.html', viewModel: () => import('./options/options02.js'), title: 'Single Radio' }, + { + name: 'options03', + view: '/src/options/options03.html', + viewModel: () => import('./options/options03.js'), + title: 'Hide Select All', + }, + { + name: 'options04', + view: '/src/options/options04.html', + viewModel: () => import('./options/options04.js'), + title: 'Hide Optgroup Checkboxes', + }, + { + name: 'options05', + view: '/src/options/options05.html', + viewModel: () => import('./options/options05.js'), + title: 'Custom Dropdown Width', + }, + { name: 'options06', view: '/src/options/options06.html', viewModel: () => import('./options/options06.js'), title: 'Max Height' }, + { + name: 'options07', + view: '/src/options/options07.html', + viewModel: () => import('./options/options07.js'), + title: 'Max Height Unit', + }, + { name: 'options08', view: '/src/options/options08.html', viewModel: () => import('./options/options08.js'), title: 'The Position' }, + { + name: 'options09', + view: '/src/options/options09.html', + viewModel: () => import('./options/options09.js'), + title: 'Display Values', + }, + { name: 'options10', view: '/src/options/options10.html', viewModel: () => import('./options/options10.js'), title: 'Display Title' }, + { + name: 'options11', + view: '/src/options/options11.html', + viewModel: () => import('./options/options11.js'), + title: 'Display Delimiter', + }, + { + name: 'options12', + view: '/src/options/options12.html', + viewModel: () => import('./options/options12.js'), + title: 'Minimum Count Selected', + }, + { name: 'options13', view: '/src/options/options13.html', viewModel: () => import('./options/options13.js'), title: 'The Ellipsis' }, + { name: 'options14', view: '/src/options/options14.html', viewModel: () => import('./options/options14.js'), title: 'Is Open' }, + { name: 'options15', view: '/src/options/options15.html', viewModel: () => import('./options/options15.js'), title: 'Keep Open' }, + { name: 'options16', view: '/src/options/options16.html', viewModel: () => import('./options/options16.js'), title: 'Open On Hover' }, + { name: 'options17', view: '/src/options/options17.html', viewModel: () => import('./options/options17.js'), title: 'The Container' }, + { name: 'options18', view: '/src/options/options18.html', viewModel: () => import('./options/options18.js'), title: 'The Filter' }, + { + name: 'options19', + view: '/src/options/options19.html', + viewModel: () => import('./options/options19.js'), + title: 'Filter Only Optgroup', + }, + { + name: 'options20', + view: '/src/options/options20.html', + viewModel: () => import('./options/options20.js'), + title: 'Filter Placeholder', + }, + { + name: 'options21', + view: '/src/options/options21.html', + viewModel: () => import('./options/options21.js'), + title: 'Filter Accept On Enter', + }, + { + name: 'options22', + view: '/src/options/options22.html', + viewModel: () => import('./options/options22.js'), + title: 'Filter By Data Length', + }, + { name: 'options23', view: '/src/options/options23.html', viewModel: () => import('./options/options23.js'), title: 'Custom Filter' }, + { name: 'options24', view: '/src/options/options24.html', viewModel: () => import('./options/options24.js'), title: 'Show Clear' }, + { + name: 'options25', + view: '/src/options/options25.html', + viewModel: () => import('./options/options25.js'), + title: 'Show OK Button', + }, + { name: 'options26', view: '/src/options/options26.html', viewModel: () => import('./options/options26.js'), title: 'The Styler' }, + { name: 'options27', view: '/src/options/options27.html', viewModel: () => import('./options/options27.js'), title: 'Text Template' }, + { + name: 'options28', + view: '/src/options/options28.html', + viewModel: () => import('./options/options28.js'), + title: 'Label Template', + }, + { + name: 'options29', + view: '/src/options/options29.html', + viewModel: () => import('./options/options29.js'), + title: 'Auto-Adjust Drop Position', + }, + { + name: 'options30', + view: '/src/options/options30.html', + viewModel: () => import('./options/options30.js'), + title: 'Auto-Adjust Drop Height/Width', + }, + { + name: 'options31', + view: '/src/options/options31.html', + viewModel: () => import('./options/options31.js'), + title: 'Use Select Option as Label', + }, + { name: 'options32', view: '/src/options/options32.html', viewModel: () => import('./options/options32.js'), title: 'Sanitizer' }, + { name: 'options33', view: '/src/options/options33.html', viewModel: () => import('./options/options33.js'), title: 'Classes' }, + { + name: 'options34', + view: '/src/options/options34.html', + viewModel: () => import('./options/options34.js'), + title: 'Show Search Clear', + }, + { + name: 'options35', + view: '/src/options/options35.html', + viewModel: () => import('./options/options35.js'), + title: 'Custom Diacritic Parser', + }, + { + name: 'options36', + view: '/src/options/options36.html', + viewModel: () => import('./options/options36.js'), + title: 'Infinite Scroll', + }, + { + name: 'options37', + view: '/src/options/options37.html', + viewModel: () => import('./options/options37.js'), + title: 'Navigation Highlight', + }, + { name: 'options38', view: '/src/options/options38.html', viewModel: () => import('./options/options38.js'), title: 'Dark Mode' }, + { + name: 'options39', + view: '/src/options/options39.html', + viewModel: () => import('./options/options39.js'), + title: 'Label Id (aria-labelledby)', + }, + { + name: 'options40', + view: '/src/options/options40.html', + viewModel: () => import('./options/options40.js'), + title: 'Pre-Filter Data', + }, + { name: 'options41', view: '/src/options/options41.html', viewModel: () => import('./options/options41.js'), title: 'Pre-Sort Data' }, + { + name: 'options42', + view: '/src/options/options42.html', + viewModel: () => import('./options/options42.js'), + title: 'Lazy Load Data', + }, + { name: 'options43', view: '/src/options/options43.html', viewModel: () => import('./options/options43.js'), title: 'Close on Tab' }, + { name: 'options44', view: '/src/options/options44.html', viewModel: () => import('./options/options44.js'), title: 'tabIndex' }, ], }, { name: 'Methods', routes: [ - { name: 'methods01', view: '/src/methods/methods01.html', viewModel: Methods01, title: 'The getOptions' }, - { name: 'methods02', view: '/src/methods/methods02.html', viewModel: Methods02, title: 'The refreshOptions' }, - { name: 'methods03', view: '/src/methods/methods03.html', viewModel: Methods03, title: 'The setSelects/getSelects' }, - { name: 'methods04', view: '/src/methods/methods04.html', viewModel: Methods04, title: 'The enable/disable' }, - { name: 'methods05', view: '/src/methods/methods05.html', viewModel: Methods05, title: 'The open/close' }, - { name: 'methods06', view: '/src/methods/methods06.html', viewModel: Methods06, title: 'The check/uncheck' }, - { name: 'methods07', view: '/src/methods/methods07.html', viewModel: Methods07, title: 'The checkAll/uncheckAll' }, - { name: 'methods08', view: '/src/methods/methods08.html', viewModel: Methods08, title: 'The checkInvert' }, - { name: 'methods09', view: '/src/methods/methods09.html', viewModel: Methods09, title: 'The focus/blur' }, - { name: 'methods10', view: '/src/methods/methods10.html', viewModel: Methods10, title: 'The refresh' }, - { name: 'methods11', view: '/src/methods/methods11.html', viewModel: Methods11, title: 'The destroy' }, - { name: 'methods12', view: '/src/methods/methods12.html', viewModel: Methods12, title: 'The getData' }, - { name: 'methods13', view: '/src/methods/methods13.html', viewModel: Methods13, title: 'The setData' }, + { + name: 'methods01', + view: '/src/methods/methods01.html', + viewModel: () => import('./methods/methods01.js'), + title: 'The getOptions', + }, + { + name: 'methods02', + view: '/src/methods/methods02.html', + viewModel: () => import('./methods/methods02.js'), + title: 'The refreshOptions', + }, + { + name: 'methods03', + view: '/src/methods/methods03.html', + viewModel: () => import('./methods/methods03.js'), + title: 'The setSelects/getSelects', + }, + { + name: 'methods04', + view: '/src/methods/methods04.html', + viewModel: () => import('./methods/methods04.js'), + title: 'The enable/disable', + }, + { + name: 'methods05', + view: '/src/methods/methods05.html', + viewModel: () => import('./methods/methods05.js'), + title: 'The open/close', + }, + { + name: 'methods06', + view: '/src/methods/methods06.html', + viewModel: () => import('./methods/methods06.js'), + title: 'The check/uncheck', + }, + { + name: 'methods07', + view: '/src/methods/methods07.html', + viewModel: () => import('./methods/methods07.js'), + title: 'The checkAll/uncheckAll', + }, + { + name: 'methods08', + view: '/src/methods/methods08.html', + viewModel: () => import('./methods/methods08.js'), + title: 'The checkInvert', + }, + { + name: 'methods09', + view: '/src/methods/methods09.html', + viewModel: () => import('./methods/methods09.js'), + title: 'The focus/blur', + }, + { name: 'methods10', view: '/src/methods/methods10.html', viewModel: () => import('./methods/methods10.js'), title: 'The refresh' }, + { name: 'methods11', view: '/src/methods/methods11.html', viewModel: () => import('./methods/methods11.js'), title: 'The destroy' }, + { name: 'methods12', view: '/src/methods/methods12.html', viewModel: () => import('./methods/methods12.js'), title: 'The getData' }, + { name: 'methods13', view: '/src/methods/methods13.html', viewModel: () => import('./methods/methods13.js'), title: 'The setData' }, ], }, { name: 'Events', - routes: [{ name: 'events', view: '/src/events/events.html', viewModel: Events, title: 'The Events' }], + routes: [{ name: 'events', view: '/src/events/events.html', viewModel: () => import('./events/events.js'), title: 'The Events' }], }, { name: 'I18N', - routes: [{ name: 'i18n', view: '/src/i18n/i18n.html', viewModel: I18n, title: 'The i18n' }], + routes: [{ name: 'i18n', view: '/src/i18n/i18n.html', viewModel: () => import('./i18n/i18n.js'), title: 'The i18n' }], }, ]; diff --git a/packages/demo/src/app.ts b/packages/demo/src/app.ts new file mode 100644 index 000000000..522c51875 --- /dev/null +++ b/packages/demo/src/app.ts @@ -0,0 +1,133 @@ +import { BindingEventService, createDomElement } from 'multiple-select-vanilla'; + +import { exampleRouting, navbarRouting } from './app-routing.js'; +import mainHtml from './main.html?raw'; +import { Renderer } from './renderer.js'; + +const pageLayoutGlobs = import.meta.glob(['/src/**/*.html', '!/src/main.html'], { query: '?raw', import: 'default' }); + +export class App { + private bindings = new BindingEventService(); + private renderer!: Renderer; + private currentRouteName = ''; + private routeLoadId = 0; + private defaultRouteName = 'getting-started'; + private routes = [...navbarRouting, ...exampleRouting.flatMap(group => group.routes)]; + + init() { + new Renderer(document.querySelector('#app')!).loadView(mainHtml); + this.renderer = new Renderer(document.querySelector('.panel-wm-content')!); + this.createRouteLinks(); + window.addEventListener('popstate', this.loadCurrentRoute); + window.addEventListener('hashchange', this.loadCurrentRoute); + window.addEventListener('beforeunload', this.dispose); + this.loadCurrentRoute(); + } + + private loadCurrentRoute = () => { + const routeName = window.location.hash.replace(/^#\/?/, '') || this.defaultRouteName; + if (routeName !== this.currentRouteName) { + void this.loadRoute(routeName, false); + } + }; + + createRouteLinks() { + for (const navRoute of navbarRouting) { + // + const liElm = createDomElement('li', { className: 'nav-item' }); + const aElm = createDomElement( + 'a', + { id: navRoute.name, href: `#/${navRoute.name}`, className: 'nav-link', textContent: navRoute.title }, + liElm, + ); + this.bindings.bind(aElm, 'click', this.clickEventListener); + document.querySelector('.navbar-nav')?.appendChild(liElm); + } + + for (const groupRoute of exampleRouting) { + const groupLiElm = createDomElement('li', { className: 'm-1' }); + createDomElement('p', { className: 'navbar-vertical-label mb-1', textContent: groupRoute.name }, groupLiElm); + document.querySelector('.nav-pills')?.appendChild(groupLiElm); + + for (const singleRoute of groupRoute.routes) { + // + const liElm = createDomElement('li', { className: 'nav-item' }); + const aElm = createDomElement('a', { + id: singleRoute.name, + href: `#/${singleRoute.name}`, + className: 'nav-link', + textContent: singleRoute.title, + }); + this.bindings.bind(aElm, 'click', this.clickEventListener); + + liElm.appendChild(aElm); + document.querySelector('.nav-pills')?.appendChild(liElm); + } + } + } + + async loadRoute(routeName: string, changeBrowserState = true) { + const route = this.routes.find(candidate => candidate.name === routeName) ?? navbarRouting[0]; + const loadId = ++this.routeLoadId; + this.currentRouteName = route.name; + this.renderer.dispose(); + this.renderer.renderLoading(); + this.updateActiveLinks(route.name); + + // Update history when navigation starts so Back also cancels a pending route load. + const hash = `#/${route.name}`; + if (changeBrowserState && window.location.hash !== hash) { + window.history.pushState({}, '', hash); + } else if (!changeBrowserState && window.location.hash !== hash) { + window.history.replaceState({}, '', hash); + } + document.title = `Multiple-Select-Vanilla · ${route.name}`; + + try { + const loadView = pageLayoutGlobs[route.view]; + if (!loadView) { + throw new Error(`Missing view: ${route.view}`); + } + const [vmModule, html] = await Promise.all([route.viewModel(), loadView()]); + // A slower navigation must never mount over the user's latest destination. + if (loadId !== this.routeLoadId) { + return; + } + this.renderer.loadViewModel(vmModule.default); + this.renderer.loadView(html); + this.renderer.mount(); + } catch (error) { + if (loadId !== this.routeLoadId) { + return; + } + console.error(`Unable to load route "${route.name}"`, error); + this.renderer.renderError('Unable to load this page. Select the link again to retry.'); + } + } + + private clickEventListener = (event: Event) => { + const mouseEvent = event as MouseEvent; + if (mouseEvent.button > 0 || mouseEvent.ctrlKey || mouseEvent.metaKey || mouseEvent.shiftKey || mouseEvent.altKey) { + return; + } + event.preventDefault(); + void this.loadRoute((event.currentTarget as HTMLAnchorElement).id); + }; + + private updateActiveLinks(routeName: string) { + document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link').forEach(link => { + link.classList.toggle('active', link.id === routeName || (routeName === 'examples' && link.id === 'example01')); + }); + document.getElementById(routeName)?.scrollIntoView({ block: 'nearest' }); + } + + private dispose = () => { + ++this.routeLoadId; + this.bindings.dispose(); + window.removeEventListener('popstate', this.loadCurrentRoute); + window.removeEventListener('hashchange', this.loadCurrentRoute); + window.removeEventListener('beforeunload', this.dispose); + this.renderer?.dispose(); + this.currentRouteName = ''; + }; +} diff --git a/packages/demo/src/interfaces.ts b/packages/demo/src/interfaces.ts new file mode 100644 index 000000000..8079869dc --- /dev/null +++ b/packages/demo/src/interfaces.ts @@ -0,0 +1,16 @@ +export interface ViewModel { + mount?: () => void; + unmount?: () => void; +} + +export interface ViewRouter { + name: string; + view: string; + viewModel: () => Promise<{ default: new () => ViewModel }>; + title: string; +} + +export interface RouteGroup { + name: string; + routes: ViewRouter[]; +} diff --git a/packages/demo/src/main.ts b/packages/demo/src/main.ts index 8e3046ad4..385838b94 100644 --- a/packages/demo/src/main.ts +++ b/packages/demo/src/main.ts @@ -1,201 +1,8 @@ import 'bootstrap'; import 'bootstrap/dist/css/bootstrap.min.css'; -import DOMPurify from 'dompurify'; import 'font-awesome/css/font-awesome.css'; -import { createDomElement, emptyElement } from 'multiple-select-vanilla'; -import { exampleRouting, navbarRouting } from './app-routing.js'; -import mainHtml from './main.html?raw'; +import { App } from './app.js'; import './style.scss'; -const pageLayoutGlobs = import.meta.glob('/src/./**/*.html', { query: '?raw', eager: true, import: 'default' }); - -interface ViewRouter { - name: string; - view: string; - viewModel: any; - title: string; -} - -interface ViewModel { - mount?: () => void; - unmount?: () => void; -} - -class Main { - loading = true; - currentModel?: ViewModel; - currentRouter?: ViewRouter; - defaultRouteName = 'getting-started'; - stateBangChar = '#/'; - baseUrl = window.location.origin + window.location.pathname; - viewModelObj: any = {}; - - async init() { - const location = window.location; - document.querySelector('#app')!.innerHTML = DOMPurify.sanitize(mainHtml, { - RETURN_TRUSTED_TYPE: true, - }) as unknown as string; - - let route = location.hash.replace(this.stateBangChar, ''); - if (!route || route === '/' || route === '#') { - route = this.defaultRouteName; - } - - // create all routes and always load first route Example01 - this.createRouteLinks(); - this.loadRoute(route); - - // on page load make route active, when changing route afterward that will be covered by each nav click events - Array.from(document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link')).forEach(link => { - if (route.includes(link.id)) { - link.classList.add('active'); - } - }); - - // re-render on browser history navigation change - window.onpopstate = () => { - const winLoc = window.location; - const prevRoute = winLoc.hash.replace(this.stateBangChar, ''); - - // change active link to previous route - this.removeAllActiveLinks(); - const navItemElm = document.querySelector(`#${prevRoute}`); - if (navItemElm) { - navItemElm.scrollIntoView(); - navItemElm.classList.add('active'); - } - this.loadRoute(prevRoute || this.defaultRouteName, false); - }; - } - - createRouteLinks() { - for (const navRoute of navbarRouting) { - // - const liElm = createDomElement('li', { className: 'nav-item' }); - const aElm = createDomElement('a', { id: navRoute.name, className: 'nav-link', textContent: navRoute.title }, liElm); - aElm.addEventListener('click', this.clickEventListener.bind(this)); - document.querySelector('.navbar-nav')?.appendChild(liElm); - } - - for (const groupRoute of exampleRouting) { - const groupLiElm = createDomElement('li', { className: 'm-1' }); - createDomElement('p', { className: 'navbar-vertical-label mb-1', textContent: groupRoute.name }, groupLiElm); - document.querySelector('.nav-pills')?.appendChild(groupLiElm); - - for (const singleRoute of groupRoute.routes) { - // - const liElm = createDomElement('li', { className: 'nav-item' }); - const aElm = createDomElement('a', { - id: singleRoute.name, - className: 'nav-link', - textContent: singleRoute.title, - }); - aElm.addEventListener('click', this.clickEventListener.bind(this)); - - liElm.appendChild(aElm); - document.querySelector('.nav-pills')?.appendChild(liElm); - } - } - } - - async loadRoute(routeName: string, changeBrowserState = true) { - const contentElm = document.querySelector('.panel-wm-content') as HTMLElement; - emptyElement(contentElm); - contentElm.classList.add('cloak'); - let foundRouter = navbarRouting.find(r => r.name === routeName); - - if (foundRouter?.name === 'examples') { - const exampleElm = document.querySelector('.nav-pills .nav-item a.nav-link'); - exampleElm?.classList.add('active'); - } else { - for (const groupRoute of exampleRouting) { - const found = (groupRoute.routes as ViewRouter[]).find(r => r.name === routeName); - if (found) { - foundRouter = found; - } - } - } - - if (this.currentModel) { - this.unmountCurrentVM(this.currentModel, this.currentRouter); - } - - if (foundRouter) { - this.currentRouter = foundRouter; - document.querySelector('.panel-wm-content')!.innerHTML = DOMPurify.sanitize(pageLayoutGlobs[foundRouter.view] as string, { - RETURN_TRUSTED_TYPE: true, - }) as unknown as string; - const vm = new foundRouter.viewModel() as ViewModel; - this.currentModel = vm; - (window as any)[foundRouter.name] = vm.mount?.(); - - // before leaving the page/SPA, we'll unmount everything - window.onbeforeunload = () => { - contentElm.classList.add('cloak'); - vm.unmount?.(); - this.removeAllActiveLinks(true); - this.unmountAll(); - if (foundRouter?.name) { - delete (window as any)[foundRouter.name]; - } - }; - } - - if (changeBrowserState) { - window.history.pushState({}, routeName, `${this.baseUrl}${this.stateBangChar}${routeName}`); - } - document.title = `Multiple-Select-Vanilla · ${routeName}`; - contentElm.classList.remove('cloak'); - } - - async clickEventListener(e: Event) { - // remove any active links - this.removeAllActiveLinks(); - - // change active link to new route - const targetElm = e.target as HTMLElement; - targetElm.classList.toggle('active'); - - this.loadRoute(targetElm.id); - } - - removeAllActiveLinks(unbindListeners = false) { - document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link').forEach(link => { - link.classList.remove('active'); - if (unbindListeners) { - link.removeEventListener('click', this.clickEventListener.bind(this) as EventListener); - } - }); - } - - unmountCurrentVM(vm: ViewModel, vr?: ViewRouter) { - vm.unmount?.(); - if (vr) { - delete (window as any)[vr.name]; - } - } - - unmountAll() { - for (const vmKey of Object.keys(this.viewModelObj)) { - const viewModel = this.viewModelObj[vmKey]; - if (typeof viewModel?.unmount === 'function') { - viewModel?.unmount(); - - // also clear all of its variable references to avoid detached elements - for (const ref of Object.keys(viewModel)) { - viewModel[ref] = null; - } - } - // nullify the object and then delete them to make sure they will be removed by the garbage collector - (window)[vmKey] = null; - this.viewModelObj[vmKey] = null; - delete (window)[vmKey]; - delete this.viewModelObj[vmKey]; - } - } -} - -// execute main init -const main = new Main(); -main.init(); +new App().init(); diff --git a/packages/demo/src/renderer.ts b/packages/demo/src/renderer.ts new file mode 100644 index 000000000..129fbdefc --- /dev/null +++ b/packages/demo/src/renderer.ts @@ -0,0 +1,46 @@ +import DOMPurify from 'dompurify'; +import { createDomElement, emptyElement } from 'multiple-select-vanilla'; + +import type { ViewModel } from './interfaces.js'; + +/** Own the active view and its model, disposing the model before removing its DOM. */ +export class Renderer { + private viewModel?: ViewModel; + + constructor(private viewTemplate: HTMLElement) {} + + loadViewModel(Model: new () => ViewModel) { + this.viewModel = new Model(); + return this.viewModel; + } + + loadView(html: string) { + this.viewTemplate.innerHTML = DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }) as unknown as string; + } + + renderLoading() { + this.viewTemplate.textContent = 'Loading...'; + this.viewTemplate.setAttribute('aria-busy', 'true'); + } + + renderError(message: string) { + this.dispose(); + createDomElement('div', { className: 'alert alert-danger', role: 'alert', textContent: message }, this.viewTemplate); + } + + mount() { + this.viewModel?.mount?.(); + this.viewTemplate.removeAttribute('aria-busy'); + } + + dispose() { + const viewModel = this.viewModel; + this.viewModel = undefined; + try { + viewModel?.unmount?.(); + } finally { + emptyElement(this.viewTemplate); + this.viewTemplate.removeAttribute('aria-busy'); + } + } +} diff --git a/playwright/e2e/routing.spec.ts b/playwright/e2e/routing.spec.ts new file mode 100644 index 000000000..1930cfb93 --- /dev/null +++ b/playwright/e2e/routing.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; + +// Match Vite development modules and production chunks, including raw HTML modules. +const example02Module = /\/(?:src\/examples\/)?example02(?:\.ts|(?:\.html)?-[^/]+\.js|\.html\?raw)/; + +test.describe('SPA routing', () => { + test('loads example modules only after navigation and supports browser history', async ({ page }) => { + const requests: string[] = []; + page.on('request', request => requests.push(request.url())); + await page.goto('/'); + await expect(page.locator('.bd-title')).toHaveText('Getting Started'); + expect(requests.filter(url => /\/(?:src\/examples\/)?example\d+/.test(url))).toEqual([]); + + await page.locator('#example01').click(); + await expect(page.locator('.ms-parent')).toHaveCount(2); + expect(requests.some(url => /\/example01[.-]/.test(url))).toBe(true); + expect(requests.some(url => example02Module.test(url))).toBe(false); + + await page.locator('#example02').click(); + await expect(page.locator('.bd-title')).toContainText('Multiple Select'); + expect(requests.some(url => example02Module.test(url))).toBe(true); + await page.goBack(); + await expect(page.locator('#example01')).toHaveClass(/active/); + await expect(page.locator('.bd-title')).toContainText('Single Select'); + await page.goBack(); + await expect(page.locator('.bd-title')).toHaveText('Getting Started'); + await page.goForward(); + await expect(page.locator('.ms-parent')).toHaveCount(2); + await expect(page).toHaveTitle('Multiple-Select-Vanilla · example01'); + }); + + test('ignores a slow route after a newer navigation completes', async ({ page }) => { + let release!: () => void; + const pending = new Promise(resolve => { + release = resolve; + }); + await page.route(example02Module, async route => { + await pending; + await route.continue(); + }); + await page.goto('#/example01'); + await expect(page.locator('.ms-parent')).toHaveCount(2); + const requested = page.waitForRequest(example02Module); + await page.locator('#example02').click(); + await requested; + await expect(page.locator('.panel-wm-content')).toHaveText('Loading...'); + await page.locator('#getting-started').click(); + await expect(page.locator('.bd-title')).toHaveText('Getting Started'); + const loaded = page.waitForEvent('requestfinished', request => example02Module.test(request.url())); + release(); + await loaded; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await expect(page.locator('.bd-title')).toHaveText('Getting Started'); + await expect(page.locator('.ms-parent')).toHaveCount(0); + await expect(page).toHaveURL(/#\/getting-started$/); + await expect(page.locator('#getting-started')).toHaveClass(/active/); + }); + + test('cleans up dropdowns outside the route and dark mode on navigation', async ({ page }) => { + await page.goto('#/options17'); + await page.locator('.select4.ms-parent').click(); + await expect(page.locator('body > .ms-drop')).toBeVisible(); + await page.locator('#example15').click(); + await expect(page.locator('.panel-wm-content')).toHaveClass(/dark-mode/); + await expect(page.locator('body > .ms-drop')).toHaveCount(0); + await page.locator('#example01').click(); + await expect(page.locator('.ms-parent')).toHaveCount(2); + await expect(page.locator('.panel-wm-content')).not.toHaveClass(/dark-mode/); + await page.locator('#options17').click(); + await page.locator('.select4.ms-parent').click(); + await expect(page.locator('body > .ms-drop')).toHaveCount(1); + }); + + test('supports direct hashes, the Examples alias, and unknown routes', async ({ page }) => { + await page.goto('#/missing-route'); + await expect(page.locator('.bd-title')).toHaveText('Getting Started'); + await expect(page).toHaveURL(/#\/getting-started$/); + await page.locator('#examples').click(); + await expect(page.locator('.ms-parent')).toHaveCount(2); + await expect(page.locator('#examples')).toHaveClass(/active/); + await expect(page.locator('#example01')).toHaveClass(/active/); + await page.evaluate(() => { + window.location.hash = '#/methods04'; + }); + await expect(page.locator('#disableBtn')).toBeVisible(); + await expect(page.locator('#methods04')).toHaveClass(/active/); + await page.locator('#disableBtn').click(); + await expect(page.locator('.ms-choice')).toBeDisabled(); + }); + + test('shows a route load failure and allows navigation to another page', async ({ page }) => { + await page.route(example02Module, route => route.abort()); + await page.goto('#/example02'); + await expect(page.getByRole('alert')).toContainText('Unable to load this page'); + await expect(page.locator('.panel-wm-content')).not.toHaveAttribute('aria-busy'); + await page.locator('#example01').click(); + await expect(page.locator('.ms-parent')).toHaveCount(2); + await expect(page.getByRole('alert')).toHaveCount(0); + }); +});