survos / tour-bundle
Reusable translated tours for rendered menus and explicit Twig targets
Fund package maintenance!
Requires
- php: ^8.5
- psr/log: ^3.0
- survos/kit-bundle: ^2.5
- symfony/config: ^8.1
- symfony/dependency-injection: ^8.1
- symfony/http-kernel: ^8.1
- symfony/stimulus-bundle: ^2.0|^3.0
- symfony/translation: ^8.1
- symfony/twig-bundle: ^8.1
- symfony/ux-twig-component: ^3.0
Requires (Dev)
- phpunit/phpunit: ^13.0
- symfony/asset-mapper: ^8.1
- symfony/browser-kit: ^8.1
Suggests
- survos/field-bundle: Opt-in compiled RouteMeta description fallback
- survos/menu-bundle: Automatic rendered menu annotations
Provides
None
Conflicts
- survos/menu-bundle: <2.42.23
- survos/tabler-bundle: <2.42.23
Replaces
None
README
Reusable, translated tours of rendered menus and explicit page controls. Driver.js is the initial renderer; application code deals with tours and steps. Requires PHP 8.5, Symfony 8.1, kit-bundle, TwigComponent and Stimulus with AssetMapper. Tabler, menu-bundle and field-bundle are optional integrations.
Install and configure
Install survos/tour-bundle through the application's configured package repository:
composer require survos/tour-bundle:^2.42.23 php bin/console importmap:install php bin/console cache:clear
For menu integration, update menu-bundle and Tabler together to a compatible version (see below). For local development use the existing monolink workflow; do not copy bundles into vendor or add Composer repository overrides. On the mono workstation the linker is /Users/tac/sites/mono/link /absolute/path/to/app. Run it after Composer finishes, since a Composer update can replace symlinks.
Flex must register Survos\TourBundle\SurvosTourBundle, the required kit bundle, and the usual TwigComponent/Stimulus bundles. Its UX recipe reads assets/package.json, registers @survos/tour-bundle in assets/controllers.json, and adds driver.js and driver.js/dist/driver.css to importmap.php. The CSS autoimport is required. Keep the application's standard Stimulus bootstrap. No app tour controller is needed.
# config/packages/survos_tour.yaml survos_tour: app_id: ink # unique for applications sharing an origin translation_domain: messages # explicit and legacy tour keys route_meta_fallback: false # see public-copy policy below
PHP menu integration: no new Twig
Normal menu integration has three parts: annotate existing menu items, supply translated copy, and add a replay item to any existing rendered menu slot in PHP. No new Twig, application JavaScript, or second menu list is needed. See the complete example for files ready to adapt and an optional explicit non-menu control.
In an existing MenuSlot listener using Tabler's MenuBuilderTrait:
use Survos\MenuBundle\Attribute\AsMenuListener; use Survos\MenuBundle\Event\MenuEvent; use Survos\MenuBundle\MenuSlot; use Survos\TablerBundle\Menu\MenuBuilderTrait; use Survos\TourBundle\Model\TourStep; #[AsMenuListener(MenuSlot::PAGE_NAV)] public function pageNav(MenuEvent $event): void { $this->add($event->menu, 'paper_search', label: 'navigation.newspapers', tour: new TourStep('page', 'paper-search', order: 20)); }
Inject Survos\TourBundle\Menu\TourMenu into your listener and place the control in the slot that suits your layout:
use Survos\TourBundle\Menu\TourMenu; use Survos\TourBundle\Model\Tour; public function __construct(private readonly TourMenu $tours) {} #[AsMenuListener(MenuSlot::NAVBAR_MENU_END)] public function tourControl(MenuEvent $event): void { $this->tours->add($event->menu, new Tour('page', version: '1'), icon: 'tabler:help-circle'); }
The item inherits the existing menu theme. TourMenu::add() accepts the parent menu, a typed Tour, an optional already translated label, and an optional icon. It mounts the shared controller on the real menu link, including keyboard support, visibility filtering, and focus restoration. No layout widget is required. Use any rendered slot, submenu, or an application's dedicated custom slot; a new slot must already have a place in the layout to be visible. The bundle does not add a hard-coded tour slot to every application.
Slot choice retains the theme's responsive behavior. Tabler's NAVBAR_END ordinary links are desktop-only; NAVBAR_MENU_END remains available after expanding mobile navigation and is the slot Fotostory uses.
new Tour('page', offer: 'once') optionally offers Start / Not now in a nonmodal invitation anchored to the first visible replay item. The default offer: 'never' keeps only the menu item. Responsive copies share dismissal and show one invitation. With no eligible steps the item and its wrapper are hidden.
# translations/routes.en.yaml paper_search.tour: Find newspapers by catalogue metadata, including title and place.
This is the normal consumption API. Menus remain the source of navigation. Tour metadata is attached only after add() passes its condition, route existence and access checks. A skipped call returns the parent as before; passing tour: is safe, while blindly chaining setExtra('tour', ...) is not. Destination route names are preserved in the tourRoute extra, independently of random Knp item names and generated URLs.
Route-less actions supply explicit copy (and their existing URI/action):
$this->add($menu, uri: '#text', label: 'Search text', tour: new TourStep('page', 'text-search', order: 30, contentKey: 'navigation.text_search_help', domain: 'messages', parameters: ['%paper%' => $paperName], )); $this->addSubmenu($menu, 'More', tour: new TourStep('page', 'more', content: 'More ways to explore this paper.'));
With menu-bundle alone, use ordinary Knp extras on an actually created and authorized item:
$item->setExtra('tour', new TourStep('page', 'paper-search')); $item->setExtra('tourRoute', 'paper_search');
Both Tabler menu_renderer: survos and menu_renderer: knp are supported. Knp mode also works when SurvosMenuBundle is not registered: Tabler installs the same generic link decoration hook. These changes to menu-bundle, Tabler and tour-bundle were released together in 2.42.23; use ^2.42.23 or later for each installed integration package. Older Tabler versions do not resolve tour extras. Custom legacy templates must call menu_link_attributes(item) where they render a link, and escape attribute values. Composable themes already receive decorated item.linkAttributes.
Typed metadata
Survos\TourBundle\Model\TourStep is the author-facing, immutable PHP metadata DTO. Constructor arguments start with Tour|string $tour, stable string $id, and int $order = 0. Named options are title, content, titleKey, contentKey, domain, parameters, placement, selector, and instance. Invalid identities and placements fail at construction. Arrays and Tabler tourOptions() remain supported for compatibility.
Survos\TourBundle\Model\Tour holds name, version and invitation offer. Share it with steps using new TourStep($tour, 'paper-search', order: 20) and pass it to TourMenu::add() or the optional widget as <twig:Tour :tour="tour" />. The string-name widget API remains available. Copy resolution produces a separate ResolvedTourStep; consumers never construct translated renderer payloads.
$tour = new Tour(name: 'page', version: '2', offer: 'once'); $step = new TourStep($tour, 'paper-search', order: 20);
The Tour object on a step identifies its tour; the replay control owns invitation/version behavior. Pass that same definition to TourMenu::add() or the optional widget when centralizing those options.
Optional Twig placement
For a page that deliberately places its control outside a menu, use the component:
<twig:Tour name="page" offer="once" version="1" /> {# Or: <twig:Tour :tour="pageTour" /> #}
Choose a PHP menu control or a Twig widget for a given named tour. Both use the same step descriptors and shared controller. A menu control needs no companion widget. Adding Tour:Step descriptors for non-menu controls is independent of replay placement.
Explicit controls
For controls that are not menu items, add a descriptor near the control. It joins the same named tour:
<button id="scan-text-toggle" type="button">Scan / Text</button> <twig:Tour:Step name="page" id="scan-text" selector="#scan-text-toggle" title="Scan and text" contentKey="textsheet.switch_help" domain="messages" :order="50" />
Tour:Step accepts name, required id and selector, title/titleKey, content/contentKey, domain, parameters, order, and placement (top, right, bottom, left, over). domain defaults to the configured domain. Missing selectors and selectors matching only hidden elements are harmless. A selector matching responsive copies uses the first visible match. Descriptor markup is inert and hidden.
Copy resolution and compatibility
Title precedence is explicit title (literal) or titleKey (translated), then the menu's translated label. A missing title translation falls back to the label. Description precedence:
- Explicit
content(literal) orcontentKeyindomain, withparameters. <route_name>.tourinroutes.<route_name>.infoinroutes.tour.<step-id>.contentin the configured/per-step domain.- Compiled
RouteMetaRegistry::get(routeName)->description, only withroute_meta_fallback: true.
Catalogue lookup includes Symfony locale fallbacks; a missing key is never used as explanatory prose. Missing/empty copy skips the step and emits a debug log with step ID and route. Applications own the public suitability of .info translations. RouteMeta descriptions are developer-oriented English: review them before enabling that fallback; it is not translated automatically. No runtime controller reflection is used.
Tabler's existing tourOptions() helper is deliberately preserved. Its tour key aliases name, placement is retained, and its default title: tour.<id>.title / content: tour.<id>.content are resolved as translation keys. Missing legacy keys fall through rather than appearing as prose. The helper never established a domain: the configured/per-step domain now supplies it. To join a page tour, use tourOptions('search', {tour: 'page'}), or use the direct metadata shown above. New explicit translation keys should use titleKey/contentKey; literal strings may otherwise contain punctuation freely.
Ordinary copy is text. Attributes are escaped server-side and Driver popovers escape text client-side; there is no trusted HTML boundary.
Replacing an application tour
- Add
TourStepmetadata to existing, authorized menu items and add aTourMenureplay item outside any user-role or old opt-in setting gate. Public tours still include only the menus that visitor can access. - Replace hard-coded JavaScript selector lists with menu metadata or
<twig:Tour:Step>beside non-menu controls. Keep stable target selectors on those controls. - Remove the application tour controller and its
data-controller/tour#startactions after switching the template. Keep Driver.js and its CSS installed for the bundle controller. Remove obsolete tour settings if nothing else reads them. - Check the anonymous page, a signed-in page, mobile collapsed navigation, and replay. Closed children are skipped; explain the visible parent. Next never navigates to another page: visitors can replay the tour after navigating normally.
Fotostory follows this pattern: a public navbar replay item added from PHP, typed metadata in its existing app/tenant menus, and explicit home-page controls. Its archive and beta-feature gates remain application concerns. Content refinement can happen independently in application translation catalogues.
If the widget stays hidden, check for at least one visible target with usable copy, verify Flex registered the bundle/controller and CSS, and clear the app cache after linking source. Missing copy is reported at debug level. Rendering PHP attributes alone does not prove that the browser controller mounted.
Identity, visibility and lifecycle
- Every step needs a stable explicit
id; neither routes nor random Knp names supply it. Menu identities are<root-slot>[:<instance>]:<id>. Setinstancein metadata when the same slot/step represents distinct contexts. Use the same identity for desktop/mobile copies so only the first visible copy is included. Keep explicit step IDs distinct from scoped menu IDs. - Use a menu control (with responsive copies allowed) or one widget per named tour. Different names are independent; starting one closes the previous overlay. Numeric
ordersorts ascending, with rendered DOM order breaking ties. - The first-visit UI is an inline or menu-anchored invitation with Start / Not now. It never opens a modal automatically.
offer="never"(default) shows only the permanent “Explain this page” replay control. No eligible targets means no invitation or replay control. - Starting or declining dismisses that invitation for the application/name/version. Replay always remains available. Storage failures fall back to the current controller's memory. Bump
versionwhen a revised tour should invite visitors again. - Closed dropdown and collapsed mobile children are skipped. Annotate their visible parent explicitly. The tour never opens, closes, clicks or navigates a menu item. Opening a menu normally makes its children eligible on the next replay. If any captured target disappears or becomes hidden during a tour, it closes safely; replay collects a fresh list.
- Back / Next / Done, progress, Escape / Close, arrow keys and dialog-only Tab cycling are supported. Focus returns to the initiating control, or replay if it is no longer visible. Highlighted navigation cannot be activated during the tour. Reduced motion disables Driver animation and smooth scrolling.
- Disconnect destroys the overlay and removes observers/listeners; reconnect rediscovers the live DOM. The controller uses DOM events, not a persistent global Driver singleton.
Extension boundary
Menu-bundle exposes LinkAttributesDecoratorInterface::decorate(ItemInterface $item, string $translatedLabel, array $renderOptions): array. Tag services survos_menu.link_attributes_decorator. Composable rendering calls it after filtering; legacy templates call the shared menu_link_attributes() function only for items they render. The tour adapter returns an escaped-at-render-time data-tour-step descriptor on the existing target. It creates no second menu registry and adds no Driver dependency to menu-bundle. Visibility checks in JavaScript are presentation checks, not authorization; listeners/builders must enforce access before rendering any sensitive item or copy.
Validation fixture (from mono root)
vendor/bin/phpunit -c bu/tour-bundle/phpunit.dist.xml vendor/bin/phpunit --bootstrap bu/menu-bundle/tests/bootstrap.php bu/menu-bundle/tests vendor/bin/phpunit --bootstrap bu/tour-bundle/tests/bootstrap.php bu/tabler-bundle/tests/Menu npm ci --prefix bu/tour-bundle/tests/browser # Install Playwright Chromium once, or use an existing Chrome with PW_CHANNEL=chrome. cd bu/tour-bundle/tests/browser npx playwright install chromium npm test
The test-only kernel exercises real bundle registration, container compilation, named component rendering, UX controller discovery, CSS autoimport metadata, asset mapping, anonymous/user/admin menus, locale fallback, denied/skipped builder safety, route-less actions, scoped identities, and a full legacy Tabler kernel without SurvosMenuBundle. A standalone kernel renders the explicit components without either menu bundle or Tabler. Runtime Composer metadata in the fixture simulates installation without vendor edits or repository overrides.
Playwright bundles the same production controller with real Driver.js/Stimulus and serves the PHP-rendered fixture; its small fixture script only operates ordinary mobile/dropdown controls and exposes Stimulus for lifecycle assertions. The temporary server stops with the tests. Scenarios cover both renderers, keyboard/focus, replay/dismissal, responsive duplicates, closed/open menus, explicit steps, safe text, empty tours, storage errors, independent tours, versions, and disconnect/reconnect. This fixture uses representative menu markup and small presentation CSS; it does not certify every application's custom theme, Bootstrap behavior or assistive technology.
Ink is not changed by this bundle. Its follow-on should distinguish catalogue metadata, article-text search, issue dates, TextSheet and OCR source blocks. Partial/unreviewed article qualifications belong in normal result UI as well as tour copy.