diff --git a/lib/Rozier/app/utils/storybook/renderer/rzActionsMenu.ts b/lib/Rozier/app/utils/storybook/renderer/rzActionsMenu.ts
new file mode 100644
index 000000000..9ea4b17a3
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzActionsMenu.ts
@@ -0,0 +1,56 @@
+import {
+ type RzButtonOptions,
+ rzButtonRenderer,
+} from '~/utils/component-renderer/rzButton'
+
+export type RzActionsMenuItemOptions = {
+ /** HTML of an element with `popover`, opened by the button (rz-popover wrapper). */
+ popoverContent?: string
+ button: RzButtonOptions
+}
+
+export type RzActionsMenuOptions = {
+ vertical?: boolean
+ class?: string
+ items: RzActionsMenuItemOptions[]
+}
+
+const COMPONENT_CLASS_NAME = 'rz-actions-menu'
+
+/** Mirrors macros/rz_actions_menu.html.twig. */
+export function rzActionsMenuRenderer(args: RzActionsMenuOptions) {
+ const element = document.createElement('rz-actions-menu')
+ element.classList.add(COMPONENT_CLASS_NAME)
+ if (args.vertical) {
+ element.classList.add(`${COMPONENT_CLASS_NAME}--vertical`)
+ }
+ if (args.class) {
+ element.classList.add(...args.class.split(' ').filter(Boolean))
+ }
+ element.setAttribute('data-axis', 'y')
+
+ args.items.forEach((item) => {
+ const button = rzButtonRenderer(item.button)
+
+ if (!item.popoverContent) {
+ element.appendChild(button)
+ return
+ }
+
+ const popover = document.createElement('rz-popover')
+ popover.setAttribute('data-popover-offset', '24px')
+ popover.setAttribute('data-popover-placement', 'top-start')
+ popover.innerHTML = item.popoverContent
+ popover.appendChild(button)
+ element.appendChild(popover)
+ })
+
+ const handle = document.createElement('button')
+ handle.classList.add(`${COMPONENT_CLASS_NAME}__handle`)
+ handle.type = 'button'
+ handle.setAttribute('aria-label', 'Drag actions menu')
+ handle.innerHTML = ''
+ element.appendChild(handle)
+
+ return element
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzAside.ts b/lib/Rozier/app/utils/storybook/renderer/rzAside.ts
new file mode 100644
index 000000000..8d8ac446e
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzAside.ts
@@ -0,0 +1,98 @@
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+import {
+ type RzTreeOptions,
+ rzTreeRenderer,
+} from '~/utils/storybook/renderer/rzTree'
+
+export const TREE_TYPES = ['node', 'tag', 'folder'] as const
+
+export type RzAsideTreeWrapperOptions = {
+ treeType: (typeof TREE_TYPES)[number]
+ /** Without a tree, the wrapper shows the loading button, as rz-aside renders before its first fetch. */
+ tree?: RzTreeOptions
+}
+
+export type RzAsideOptions = RzAsideTreeWrapperOptions & {
+ defaultTranslationId?: string
+}
+
+const COMPONENT_CLASS_NAME = 'rz-aside'
+
+const TREE_LABELS = {
+ node: { title: 'Node tree', add: 'Add a node' },
+ tag: { title: 'Tag tree', add: 'Add a tag' },
+ folder: { title: 'Folder tree', add: 'Add a folder' },
+}
+
+/** Mirrors widgets/rz_tree_wrapper_auto.html.twig for a root tree, without the language switcher. */
+export function rzAsideTreeWrapperRenderer(args: RzAsideTreeWrapperOptions) {
+ const wrapper = document.createElement('div')
+ wrapper.classList.add(
+ 'rz-tree-wrapper',
+ 'tree-widget',
+ `${args.treeType}-tree-widget`,
+ )
+
+ if (!args.tree) {
+ wrapper.appendChild(
+ rzButtonRenderer({
+ tag: 'div',
+ label: 'Loading',
+ emphasis: 'secondary',
+ size: 'md',
+ iconClass: 'rz-spinner',
+ attributes: {
+ class: `${COMPONENT_CLASS_NAME}__loading-button`,
+ 'aria-live': 'polite',
+ role: 'status',
+ },
+ }),
+ )
+ return wrapper
+ }
+
+ const labels = TREE_LABELS[args.treeType]
+ const head = document.createElement('div')
+ head.classList.add(`${COMPONENT_CLASS_NAME}__head`)
+ head.innerHTML = `
+
${labels.title}
`
+ head.appendChild(
+ rzButtonRenderer({
+ tag: 'a',
+ iconClass: 'rz-icon-ri--add-line',
+ size: 'sm',
+ label: labels.add,
+ attributes: { class: `${COMPONENT_CLASS_NAME}__button`, href: '#' },
+ }),
+ )
+ wrapper.appendChild(head)
+
+ const tree = rzTreeRenderer(args.tree)
+ tree.classList.add(`rz-tree--${args.treeType}`)
+ tree.id = `${args.treeType}-root`
+ tree.setAttribute('group', `${args.treeType}Tree`)
+ tree.setAttribute('data-entity-type', args.treeType)
+ wrapper.appendChild(tree)
+
+ return wrapper
+}
+
+/** Mirrors widgets/rz_aside.html.twig: the element then fetches its tree. */
+export function rzAsideRenderer(args: RzAsideOptions) {
+ const element = document.createElement('rz-aside')
+ element.classList.add(COMPONENT_CLASS_NAME)
+ element.id = COMPONENT_CLASS_NAME
+ if (args.defaultTranslationId) {
+ element.setAttribute(
+ 'data-default-translation-id',
+ args.defaultTranslationId,
+ )
+ }
+
+ const body = document.createElement('div')
+ body.classList.add(`${COMPONENT_CLASS_NAME}__body`)
+ body.appendChild(rzAsideTreeWrapperRenderer({ treeType: args.treeType }))
+ element.appendChild(body)
+
+ return element
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzBulkActions.ts b/lib/Rozier/app/utils/storybook/renderer/rzBulkActions.ts
new file mode 100644
index 000000000..9b98c546a
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzBulkActions.ts
@@ -0,0 +1,129 @@
+import {
+ type RzButtonOptions,
+ rzButtonRenderer,
+} from '~/utils/component-renderer/rzButton'
+
+export type RzBulkActionsItemOptions = {
+ button: RzButtonOptions
+ /** HTML of the panel the button toggles (nodes/bulkActions.html.twig `panels` block). */
+ panel?: string
+}
+
+export type RzBulkActionsOptions = {
+ title?: string
+ /** Shown by rz-bulk-section once an item is selected. */
+ visible?: boolean
+ actions?: RzBulkActionsItemOptions[]
+ deleteFormAction?: string
+ /** Name of the hidden input rz-bulk-section fills with the selection (JSON). */
+ deleteFormInputName?: string
+}
+
+const COMPONENT_CLASS_NAME = 'rz-bulk-actions'
+
+/** Same buttons as nodes/bulkActions.html.twig: tags and status toggle a panel. */
+export const DEFAULT_BULK_ACTIONS: RzBulkActionsOptions = {
+ title: 'Bulk actions',
+ deleteFormAction: '#',
+ deleteFormInputName: 'nodes',
+ actions: [
+ {
+ button: {
+ emphasis: 'primary',
+ iconClass: 'rz-icon-ri--price-tag-3-line',
+ attributes: { type: 'button', title: 'Link tags' },
+ },
+ panel: 'Tags form
',
+ },
+ {
+ button: {
+ emphasis: 'primary',
+ iconClass: 'rz-icon-ri--flag-line',
+ attributes: { type: 'button', title: 'Nodes status' },
+ },
+ panel: 'Status form
',
+ },
+ ],
+}
+
+let instanceCounter = 0
+
+/** Mirrors widgets/rz_bulk_actions.html.twig. */
+export function rzBulkActionsRenderer(args: RzBulkActionsOptions) {
+ const prefix = `${COMPONENT_CLASS_NAME}-${++instanceCounter}`
+ const element = document.createElement('rz-bulk-actions')
+ element.classList.add(COMPONENT_CLASS_NAME)
+ element.setAttribute('visibility-class', `${COMPONENT_CLASS_NAME}--visible`)
+ if (args.visible) {
+ element.classList.add(`${COMPONENT_CLASS_NAME}--visible`)
+ }
+
+ const title = document.createElement('h3')
+ title.classList.add(`${COMPONENT_CLASS_NAME}__title`)
+ title.textContent = args.title || 'Bulk actions'
+ element.appendChild(title)
+
+ const buttons = document.createElement('div')
+ buttons.classList.add(`${COMPONENT_CLASS_NAME}__buttons`)
+ element.appendChild(buttons)
+
+ buttons.appendChild(
+ rzButtonRenderer({
+ emphasis: 'primary',
+ iconClass: 'rz-icon-ri--checkbox-blank-line',
+ attributes: {
+ type: 'button',
+ title: 'Deselect all',
+ 'data-bulk-deselect-all-button': '',
+ },
+ }),
+ )
+
+ const panels: HTMLElement[] = []
+ args.actions?.forEach((action, index) => {
+ const attributes = { ...action.button.attributes }
+ if (action.panel) {
+ const panel = document.createElement('div')
+ panel.id = `${prefix}-panel-${index}`
+ panel.classList.add(`${COMPONENT_CLASS_NAME}__group`)
+ panel.innerHTML = action.panel
+ panels.push(panel)
+
+ Object.assign(attributes, {
+ 'data-tab-controls': panel.id,
+ 'data-tab-panel-active-class': `${COMPONENT_CLASS_NAME}__group--visible`,
+ 'data-tab-active-class': 'rz-button--selected',
+ })
+ }
+ buttons.appendChild(rzButtonRenderer({ ...action.button, attributes }))
+ })
+
+ if (args.deleteFormAction) {
+ const form = document.createElement('form')
+ form.classList.add(`${COMPONENT_CLASS_NAME}__form-button`)
+ form.action = args.deleteFormAction
+ form.method = 'POST'
+
+ if (args.deleteFormInputName) {
+ const input = document.createElement('input')
+ input.type = 'hidden'
+ input.classList.add('bulk-form-value')
+ input.name = args.deleteFormInputName
+ form.appendChild(input)
+ }
+
+ form.appendChild(
+ rzButtonRenderer({
+ color: 'danger',
+ emphasis: 'primary',
+ iconClass: 'rz-icon-ri--delete-bin-7-line',
+ attributes: { type: 'submit', title: 'Delete' },
+ }),
+ )
+ buttons.appendChild(form)
+ }
+
+ panels.forEach((panel) => element.appendChild(panel))
+
+ return element
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzBulkSection.ts b/lib/Rozier/app/utils/storybook/renderer/rzBulkSection.ts
new file mode 100644
index 000000000..ca96a12d1
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzBulkSection.ts
@@ -0,0 +1,99 @@
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+import {
+ type RzBulkActionsOptions,
+ rzBulkActionsRenderer,
+} from '~/utils/storybook/renderer/rzBulkActions'
+
+export type RzBulkSectionItemOptions = {
+ value: string
+ label: string
+}
+
+export type RzBulkSectionOptions = {
+ items: RzBulkSectionItemOptions[]
+ bulkActions: RzBulkActionsOptions
+ /** Select-all button of widgets/rz_filters_bar.html.twig (`display_select_all_button`). */
+ selectAllButton?: boolean
+}
+
+/** Mirrors rz_bulk.select_all_input and rz_bulk.select_input (macros/rz_bulk.html.twig). */
+function selectInputRenderer(name: string, label: string, value?: string) {
+ const input = document.createElement('input', { is: 'rz-input' })
+ input.setAttribute('is', 'rz-input')
+ input.type = 'checkbox'
+ input.name = name
+ input.setAttribute('aria-label', label)
+ if (value) input.value = value
+
+ return input
+}
+
+function cellRenderer(tag: 'td' | 'th', content: HTMLElement | string) {
+ const cell = document.createElement(tag)
+ cell.append(content)
+ return cell
+}
+
+/** Mirrors in users/list.html.twig: filters bar, bulk actions and rz_table.html.twig. */
+export function rzBulkSectionRenderer(args: RzBulkSectionOptions) {
+ const element = document.createElement('rz-bulk-section')
+
+ if (args.selectAllButton) {
+ const filtersBar = document.createElement('div')
+ filtersBar.classList.add('rz-filters-bar')
+ filtersBar.appendChild(
+ rzButtonRenderer({
+ emphasis: 'secondary',
+ label: 'Select all',
+ iconClass: 'rz-icon-ri--checkbox-line',
+ attributes: {
+ type: 'button',
+ 'aria-selected': 'false',
+ 'data-bulk-select-all-button': '',
+ 'selected-class': 'rz-button--selected',
+ },
+ }),
+ )
+ element.appendChild(filtersBar)
+ }
+
+ element.appendChild(rzBulkActionsRenderer(args.bulkActions))
+
+ const container = document.createElement('div')
+ container.classList.add('rz-table-container')
+ const table = document.createElement('table', { is: 'rz-table' })
+ table.setAttribute('is', 'rz-table')
+ table.classList.add('rz-table', 'rz-table--default')
+ container.appendChild(table)
+
+ const headRow = document.createElement('tr')
+ headRow.append(
+ cellRenderer(
+ 'th',
+ selectInputRenderer('bulk-selection-all', 'Select all'),
+ ),
+ cellRenderer('th', 'Name'),
+ )
+ table.createTHead().appendChild(headRow)
+
+ const body = table.createTBody()
+ args.items.forEach((item) => {
+ const row = document.createElement('tr')
+ row.append(
+ cellRenderer(
+ 'td',
+ selectInputRenderer(
+ 'bulk-selection[]',
+ 'Select row',
+ item.value,
+ ),
+ ),
+ cellRenderer('td', item.label),
+ )
+ body.appendChild(row)
+ })
+
+ element.appendChild(container)
+
+ return element
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzGeotag.ts b/lib/Rozier/app/utils/storybook/renderer/rzGeotag.ts
new file mode 100644
index 000000000..ef06d235e
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzGeotag.ts
@@ -0,0 +1,194 @@
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+import { rzFormFieldHeadRenderer } from '~/utils/storybook/renderer/rzFormField'
+
+export type RzGeotagOptions = {
+ id: string
+ label: string
+ multiple?: boolean
+ /** GeoJSON: a Feature, or a FeatureCollection when `multiple`. */
+ value?: string
+ help?: string
+}
+
+const COMPONENT_CLASS_NAME = 'rz-geotag'
+
+/** Mirrors the item_detail macro of widgets/rz_geotag_field.html.twig. */
+function itemDetailRenderer(
+ componentId: string,
+ itemId = '',
+ name = '',
+ content = '',
+) {
+ const item = document.createElement('div')
+ item.classList.add(`${COMPONENT_CLASS_NAME}__item`)
+ item.id = itemId
+ item.innerHTML = `
+
+ ${name}
+ ${content}
+
`
+
+ const buttons = [
+ { command: '--target-marker', iconClass: 'rz-icon-ri--crosshair-line' },
+ {
+ command: '--delete-marker',
+ iconClass: 'rz-icon-ri--delete-bin-7-line',
+ color: 'danger' as const,
+ },
+ ]
+ buttons.forEach(({ command, iconClass, color }) => {
+ item.appendChild(
+ rzButtonRenderer({
+ size: 'xs',
+ emphasis: 'tertiary',
+ color,
+ iconClass,
+ attributes: {
+ class: `${COMPONENT_CLASS_NAME}__item__button`,
+ type: 'button',
+ commandfor: componentId,
+ command,
+ 'data-item-detail-id': itemId,
+ },
+ }),
+ )
+ })
+
+ return item
+}
+
+type Feature = {
+ type: 'Feature'
+ geometry?: { coordinates?: number[] }
+ properties?: { name?: string }
+}
+
+function parseValue(value?: string) {
+ try {
+ return value ? JSON.parse(value) : null
+ } catch {
+ return null
+ }
+}
+
+/** Mirrors widgets/rz_geotag_field.html.twig. */
+export function rzGeotagRenderer(args: RzGeotagOptions) {
+ const fieldId = args.id
+ const wrapperId = `${fieldId}-wrapper`
+ const componentId = `${fieldId}-component`
+ const searchId = `${fieldId}-search-value`
+ const itemDetailIdPrefix = `${fieldId}-item-`
+ const geo = parseValue(args.value)
+
+ const wrapper = document.createElement('rz-form-field')
+ wrapper.id = wrapperId
+
+ const head = rzFormFieldHeadRenderer({
+ label: args.label,
+ iconClass: 'rz-icon-ri--compass-3-line',
+ })
+ wrapper.appendChild(head)
+
+ const headEnd = document.createElement('div')
+ headEnd.className =
+ 'rz-button-group rz-button-group--md rz-form-field__head__end'
+ head.appendChild(headEnd)
+
+ const search = document.createElement('input', { is: 'rz-input' })
+ search.setAttribute('is', 'rz-input')
+ search.name = 'location_search'
+ search.id = searchId
+ search.type = 'search'
+ search.placeholder = 'Search a location'
+ search.setAttribute('form', '')
+ if (geo?.type === 'Feature') search.value = geo.properties?.name || ''
+ headEnd.appendChild(search)
+
+ headEnd.appendChild(
+ rzButtonRenderer({
+ size: 'sm',
+ iconClass: 'rz-icon-ri--search-line',
+ attributes: {
+ class: 'rz-form-field__head__button',
+ type: 'button',
+ id: `${fieldId}-search`,
+ title: 'Search location',
+ commandfor: componentId,
+ command: '--search-location',
+ },
+ }),
+ )
+ headEnd.appendChild(
+ rzButtonRenderer({
+ size: 'sm',
+ color: 'danger',
+ emphasis: 'tertiary',
+ iconClass: 'rz-icon-ri--delete-bin-7-line',
+ attributes: {
+ class: 'rz-form-field__head__button',
+ type: 'button',
+ id: `${fieldId}-delete`,
+ title: args.multiple
+ ? 'Delete all locations'
+ : 'Delete location',
+ commandfor: componentId,
+ command: args.multiple
+ ? '--delete-all-marker'
+ : '--delete-marker',
+ },
+ }),
+ )
+
+ if (args.help) {
+ // rz_form_field.help (macros/rz_form_field.html.twig)
+ const help = document.createElement('p')
+ help.id = `${fieldId}_help`
+ help.classList.add('rz-form-field__description')
+ help.textContent = args.help
+ wrapper.appendChild(help)
+ }
+
+ const template = document.createElement('template')
+ template.setAttribute('data-item-detail', '')
+ template.content.appendChild(itemDetailRenderer(componentId))
+ wrapper.appendChild(template)
+
+ const element = document.createElement('rz-geotag')
+ element.classList.add('rz-form-field__body')
+ element.id = componentId
+ element.setAttribute('wrapper-id', wrapperId)
+ element.setAttribute('search-input-id', searchId)
+ element.setAttribute('multiple', args.multiple ? 'true' : 'false')
+ element.setAttribute('item-detail-id-prefix', itemDetailIdPrefix)
+ wrapper.appendChild(element)
+
+ const textarea = document.createElement('textarea')
+ textarea.classList.add(`${COMPONENT_CLASS_NAME}__textarea`)
+ textarea.id = fieldId
+ textarea.name = fieldId
+ textarea.value = args.value || ''
+ element.appendChild(textarea)
+
+ if (args.multiple) {
+ const list = document.createElement('div')
+ list.classList.add(`${COMPONENT_CLASS_NAME}__item-list`)
+ list.setAttribute('data-item-detail-list', '')
+ if (geo?.type === 'FeatureCollection') {
+ ;(geo.features as Feature[]).forEach((feature, index) => {
+ const [lon, lat] = feature.geometry?.coordinates || []
+ if (lon === undefined || lat === undefined) return
+ list.appendChild(
+ itemDetailRenderer(
+ componentId,
+ `${itemDetailIdPrefix}${index}`,
+ feature.properties?.name,
+ `${lat}, ${lon}`,
+ ),
+ )
+ })
+ }
+ element.appendChild(list)
+ }
+
+ return wrapper
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzNodeTreeContextualMenu.ts b/lib/Rozier/app/utils/storybook/renderer/rzNodeTreeContextualMenu.ts
new file mode 100644
index 000000000..5e130af52
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzNodeTreeContextualMenu.ts
@@ -0,0 +1,165 @@
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+import {
+ type RzDropdownOptions,
+ rzDropdownRenderer,
+} from '~/utils/storybook/renderer/rzDropDown'
+
+export const POPOVER_PLACEMENTS = [
+ 'bottom-end',
+ 'bottom-start',
+ 'top-end',
+ 'top-start',
+] as const
+
+export type RzNodeTreeContextualMenuOptions = {
+ id?: string
+ nodeId: string
+ contextualMenuPath: string
+ statusPath?: string
+ duplicatePath?: string
+ pastePath?: string
+ editPositionPath?: string
+ copiedTrans?: string
+ popoverPlacement?: (typeof POPOVER_PLACEMENTS)[number]
+ /** Story-only prefilled popover. singleNode.html.twig renders none: the element fetches `contextualMenuPath`. */
+ content?: RzDropdownOptions
+}
+
+/** Mirrors widgets/nodeTree/contextualMenu.html.twig (without the node badges), fetched on first opening. */
+export const DEFAULT_CONTEXTUAL_MENU_CONTENT: RzDropdownOptions = {
+ title: 'Actions | Home',
+ items: [
+ [
+ {
+ tag: 'a',
+ iconClass: 'rz-icon-ri--add-line',
+ label: 'Add a child node',
+ attributes: { href: '#' },
+ },
+ {
+ tag: 'a',
+ iconClass: 'rz-icon-ri--edit-line',
+ label: 'Edit node',
+ attributes: { href: '#' },
+ },
+ {
+ tag: 'button',
+ iconClass: 'rz-icon-ri--arrow-up-line',
+ label: 'Move to first position',
+ attributes: { type: 'button', command: '--move-first' },
+ },
+ {
+ tag: 'button',
+ iconClass: 'rz-icon-ri--arrow-down-line',
+ label: 'Move to last position',
+ attributes: { type: 'button', command: '--move-last' },
+ },
+ ],
+ [
+ {
+ tag: 'button',
+ iconClass: 'rz-icon-ri--file-copy-line',
+ label: 'Copy',
+ attributes: { type: 'button', command: '--copy' },
+ },
+ {
+ tag: 'button',
+ iconClass: 'rz-icon-ri--file-copy-line',
+ label: 'Paste inside',
+ attributes: {
+ type: 'button',
+ command: '--paste-inside',
+ disabled: 'true',
+ },
+ },
+ {
+ tag: 'button',
+ iconClass: 'rz-icon-ri--file-copy-line',
+ label: 'Paste after',
+ attributes: {
+ type: 'button',
+ command: '--paste-after',
+ disabled: 'true',
+ },
+ },
+ {
+ tag: 'button',
+ iconClass: 'rz-icon-ri--file-copy-line',
+ label: 'Duplicate',
+ attributes: { type: 'button', command: '--duplicate' },
+ },
+ ],
+ [
+ {
+ tag: 'button',
+ iconClass: 'rz-icon-ri--eye-off-line',
+ label: 'Hide',
+ attributes: {
+ type: 'button',
+ command: '--update-status',
+ 'data-status': 'visible',
+ 'data-value': '0',
+ },
+ },
+ ],
+ ],
+}
+
+let instanceCounter = 0
+
+/** Mirrors the contextual_menu block of widgets/nodeTree/singleNode.html.twig. */
+export function rzNodeTreeContextualMenuRenderer(
+ args: RzNodeTreeContextualMenuOptions,
+) {
+ const id = args.id || `node-contextual-menu-${++instanceCounter}`
+ const popoverId = `${id}-popover`
+
+ const element = document.createElement('rz-node-tree-contextual-menu')
+ element.classList.add('rz-node-contextual-menu')
+ element.id = id
+ element.setAttribute('data-node-id', args.nodeId)
+ element.setAttribute('data-contextual-menu-path', args.contextualMenuPath)
+
+ const optionalAttributes: Record = {
+ 'data-node-status-path': args.statusPath,
+ 'data-node-duplicate-path': args.duplicatePath,
+ 'data-node-paste-path': args.pastePath,
+ 'data-node-copied-trans': args.copiedTrans,
+ 'data-node-edit-position-path': args.editPositionPath,
+ 'popover-placement': args.popoverPlacement || 'bottom-end',
+ }
+ Object.entries(optionalAttributes).forEach(([key, value]) => {
+ if (value) element.setAttribute(key, value)
+ })
+
+ element.appendChild(
+ rzButtonRenderer({
+ iconClass: 'rz-icon-ri--more-line',
+ emphasis: 'tertiary',
+ size: 'sm',
+ attributes: {
+ class: 'tree-contextualmenu-button',
+ 'aria-label': 'Show actions',
+ popovertarget: popoverId,
+ type: 'button',
+ },
+ }),
+ )
+
+ // Without a popover, the element creates an idle placeholder and fetches its content
+ if (args.content) {
+ const popover = document.createElement('div')
+ popover.id = popoverId
+ popover.setAttribute('popover', '')
+ popover.setAttribute('data-popover-content-state', 'fetched')
+ popover.setAttribute('data-contextual-menu-popover', '')
+ popover.appendChild(rzDropdownRenderer(args.content))
+ // contextualMenu.html.twig cannot know the instance ID
+ popover.querySelectorAll('button[command]').forEach((button) => {
+ button.setAttribute('commandfor', id)
+ })
+ element.appendChild(popover)
+ }
+
+ return element
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzPage.ts b/lib/Rozier/app/utils/storybook/renderer/rzPage.ts
new file mode 100644
index 000000000..91309f5f3
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzPage.ts
@@ -0,0 +1,42 @@
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+
+export type RzPageOptions = {
+ id?: string
+ content?: HTMLElement | string
+}
+
+const COMPONENT_CLASS_NAME = 'rz-page'
+
+/** Mirrors the of base.html.twig, without its legacy overlay, loader and UIkit hooks. */
+export function rzPageRenderer(args: RzPageOptions) {
+ const id = args.id || COMPONENT_CLASS_NAME
+ const page = document.createElement('main', { is: COMPONENT_CLASS_NAME })
+ page.setAttribute('is', COMPONENT_CLASS_NAME)
+ page.id = id
+ page.classList.add(COMPONENT_CLASS_NAME)
+
+ const scrollable = document.createElement('div')
+ scrollable.id = 'main-content-scrollable'
+ if (args.content instanceof HTMLElement) {
+ scrollable.appendChild(args.content)
+ } else if (args.content) {
+ scrollable.innerHTML = args.content
+ }
+ page.appendChild(scrollable)
+
+ // Hidden by main.css, kept to match base.html.twig
+ page.appendChild(
+ rzButtonRenderer({
+ iconClass: 'rz-icon-ri--arrow-up-line',
+ size: 'xs',
+ attributes: {
+ class: 'rz-back-to-top-button',
+ title: 'Back to top',
+ command: '--scroll-top',
+ commandfor: id,
+ },
+ }),
+ )
+
+ return page
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzSaveButton.ts b/lib/Rozier/app/utils/storybook/renderer/rzSaveButton.ts
new file mode 100644
index 000000000..2621423f7
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzSaveButton.ts
@@ -0,0 +1,24 @@
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+
+export type RzSaveButtonOptions = {
+ /** Selector of the form submitted on click and on Cmd/Ctrl+S. */
+ dataActionSave: string
+ label?: string
+}
+
+/** Mirrors rz_actions_menu.saveItem (macros/rz_actions_menu.html.twig). */
+export function rzSaveButtonRenderer(args: RzSaveButtonOptions) {
+ return rzButtonRenderer({
+ is: 'rz-save-button',
+ size: 'md',
+ onDark: true,
+ emphasis: 'primary',
+ color: 'success',
+ iconClass: 'rz-icon-ri--save-line',
+ attributes: {
+ 'data-action-save': args.dataActionSave,
+ type: 'submit',
+ title: args.label || 'Save',
+ },
+ })
+}
diff --git a/lib/Rozier/app/utils/storybook/renderer/rzTree.ts b/lib/Rozier/app/utils/storybook/renderer/rzTree.ts
new file mode 100644
index 000000000..319f48923
--- /dev/null
+++ b/lib/Rozier/app/utils/storybook/renderer/rzTree.ts
@@ -0,0 +1,193 @@
+import { rzBadgeRenderer } from '~/utils/component-renderer/rzBadge'
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+import { rzPopoverRenderer } from '~/utils/storybook/renderer/rzPopover'
+
+export type RzTreeItemOptions = {
+ label: string
+ iconClass?: string
+ icon?: HTMLElement
+ href?: string
+ expanded?: boolean
+ children?: RzTreeItemOptions[]
+ actions?: HTMLElement[]
+ /** Stack tree only: tags fold into a popover past MAX_VISIBLE_TAGS or when the row is narrow. */
+ tags?: string[]
+}
+
+/** Mirrors `visibleCount` in widgets/nodeTree/singleNode.html.twig — keep both in step. */
+const MAX_VISIBLE_TAGS = 6
+
+export type RzTreeOptions = {
+ items: RzTreeItemOptions[]
+ iconColor?: string
+ /** Adds rz-tree--child-nodes, the layout of the children nodes tree. */
+ childNodes?: boolean
+}
+
+export const COMPONENT_CLASS_NAME = 'rz-tree'
+
+function itemNodeRenderer(item: RzTreeItemOptions) {
+ const hasChildren = item.children && item.children.length > 0
+ const tag = item.href ? 'a' : 'div'
+
+ const node = document.createElement(tag)
+ node.setAttribute('role', 'treeitem')
+ node.setAttribute('aria-expanded', item.expanded ? 'true' : 'false')
+ node.classList.add(`${COMPONENT_CLASS_NAME}__item__node`)
+
+ const innerEl = document.createElement('div')
+ innerEl.classList.add(`${COMPONENT_CLASS_NAME}__item__node__inner`)
+ node.appendChild(innerEl)
+
+ const handle = document.createElement('span')
+ handle.classList.add(`${COMPONENT_CLASS_NAME}__item__handle`)
+ handle.classList.add('rz-icon-ri--draggable')
+ innerEl.appendChild(handle)
+
+ // Icon slot - use custom element if provided, otherwise use icon class
+ if (item.icon) {
+ item.icon.classList.add(`${COMPONENT_CLASS_NAME}__item__icon`)
+ innerEl.appendChild(item.icon)
+ } else if (item.iconClass) {
+ const icon = document.createElement('span')
+ icon.classList.add(`${COMPONENT_CLASS_NAME}__item__icon`)
+ icon.classList.add(item.iconClass)
+ innerEl.appendChild(icon)
+ }
+
+ const label = document.createElement('span')
+ label.classList.add(`${COMPONENT_CLASS_NAME}__item__label`)
+ label.textContent = item.label
+ innerEl.appendChild(label)
+
+ if (item.tags && item.tags.length > 0) {
+ innerEl.appendChild(tagsRenderer(item.tags))
+ }
+
+ if (hasChildren) {
+ const expandButton = rzButtonRenderer({
+ tag: 'span',
+ iconClass: 'rz-icon-ri--arrow-down-s-line',
+ emphasis: 'tertiary',
+ size: 'xs',
+ })
+ expandButton.classList.add(
+ `${COMPONENT_CLASS_NAME}__item__expand-button`,
+ )
+
+ innerEl.appendChild(expandButton)
+ }
+
+ // Actions slot - populated from item.actions
+ if (item.actions && item.actions.length > 0) {
+ const actionsSlot = document.createElement('div')
+ actionsSlot.classList.add(`${COMPONENT_CLASS_NAME}__item__actions`)
+ item.actions.forEach((action) => actionsSlot.appendChild(action))
+ innerEl.appendChild(actionsSlot)
+ }
+
+ return node
+}
+
+/** Mirrors what rz_overflow_list.html.twig emits for singleNode.html.twig with `responsive`. */
+function tagsRenderer(tags: string[]) {
+ const badge = (tag: string) =>
+ rzBadgeRenderer({ tag: 'rz-badge', label: tag, size: 'sm' })
+ const inline = tags.slice(0, MAX_VISIBLE_TAGS)
+ const folded = tags.slice(MAX_VISIBLE_TAGS)
+
+ const wrapper = document.createElement('div')
+ wrapper.className = `rz-overflow-list ${COMPONENT_CLASS_NAME}__item__tags`
+ inline.forEach((tag) => wrapper.appendChild(badge(tag)))
+
+ const list = document.createElement('div')
+ list.className = 'rz-dropdown__list rz-overflow-list__list'
+ folded.forEach((tag) => list.appendChild(badge(tag)))
+ const content = document.createElement('div')
+ content.className = `rz-dropdown rz-overflow-list__popover ${COMPONENT_CLASS_NAME}__item__tags__popover`
+ content.appendChild(list)
+
+ const { popover } = rzPopoverRenderer({
+ targetElement: {
+ element: rzButtonRenderer({
+ iconClass: 'rz-icon-ri--price-tag-3-line',
+ emphasis: 'secondary',
+ size: 'sm',
+ label: folded.length ? String(folded.length) : undefined,
+ attributes: {
+ type: 'button',
+ 'aria-label': 'More tags',
+ class: 'rz-overflow-list__button',
+ },
+ }),
+ },
+ popoverElement: {
+ element: content,
+ id: `rz-tree-tags-${Math.random().toString(36).slice(2, 8)}`,
+ },
+ offset: 8,
+ })
+ popover.setAttribute('overflow-responsive', '')
+ popover.setAttribute('overflow-inline', String(inline.length))
+ popover.setAttribute('overflow-inline-side', 'before')
+ popover.setAttribute('overflow-count', '')
+ popover.hidden = folded.length === 0
+ wrapper.appendChild(popover)
+
+ return wrapper
+}
+
+function itemRenderer(item: RzTreeItemOptions) {
+ const ITEM_LIST_CLASS = 'rz-tree-item'
+
+ const li = document.createElement('li', { is: ITEM_LIST_CLASS })
+ li.setAttribute('is', ITEM_LIST_CLASS)
+ li.classList.add(`${COMPONENT_CLASS_NAME}__item`)
+
+ const content = itemNodeRenderer(item)
+ li.appendChild(content)
+
+ if (item.children) {
+ li.classList.add(`${COMPONENT_CLASS_NAME}__item--parent`)
+
+ const list = listRenderer(item.children)
+ list.setAttribute('role', 'group')
+ li.appendChild(list)
+ } else {
+ li.classList.add(`${COMPONENT_CLASS_NAME}__item--end`)
+ }
+
+ return li
+}
+
+function listRenderer(items: RzTreeItemOptions[]) {
+ const element = document.createElement('ul')
+
+ element.classList.add(`${COMPONENT_CLASS_NAME}__list`)
+
+ items.forEach((item) => {
+ const itemEl = itemRenderer(item)
+ element.appendChild(itemEl)
+ })
+
+ return element
+}
+
+export function rzTreeRenderer(args: RzTreeOptions) {
+ const element = document.createElement('rz-tree')
+ element.classList.add(COMPONENT_CLASS_NAME)
+
+ const list = listRenderer(args.items)
+ list.setAttribute('role', 'tree')
+ element.appendChild(list)
+
+ if (args.iconColor) {
+ element.style.setProperty('--rz-tree-icon-color', args.iconColor)
+ }
+
+ if (args.childNodes) {
+ element.classList.add(`${COMPONENT_CLASS_NAME}--child-nodes`)
+ }
+
+ return element
+}
diff --git a/lib/Rozier/docs/storybook.md b/lib/Rozier/docs/storybook.md
index 551ef850f..48a149311 100644
--- a/lib/Rozier/docs/storybook.md
+++ b/lib/Rozier/docs/storybook.md
@@ -20,20 +20,23 @@ Three roots:
functional, never redesign zones: `Actions`, `Form`, `Overlay`, `Navigation`,
`Data display`, `Feedback`.
- `Patterns/`: a whole screen or a layout region (app layout, login,
- dashboard, header, aside, menu bar, live preview). An item of a pattern that is
+ dashboard, header, aside, page, menu bar, live preview). An item of a pattern that is
reused elsewhere is a component of its own (`Components/Navigation/HeaderNavItem`).
```
Foundations/ Colors, Typography/Text, Typography/FontFamily, Icons, Brand
Components/
- Actions/ Button, ButtonGroup, ActionsMenu, ColorSchemeSwitcher
+ Actions/ Button, ButtonGroup, ActionsMenu, SaveButton, ColorSchemeSwitcher,
+ BulkSection, BulkActions
Form/ Input, Textarea, Select, Switch, ColorInput, Field, Fieldset,
- Form, MarkdownEditor, Repeatable, FileUpload, ChildrenNodes/{Root,Item}
- Overlay/ Dialog, Drawer, Dropdown/{Root,Item}, Popover, Tooltip, Search
+ Form, MarkdownEditor, Repeatable, FileUpload, Geotag,
+ ChildrenNodes/{Root,Item}
+ Overlay/ Dialog, Drawer, Dropdown/{Root,Item}, Popover, Tooltip, Search,
+ NodeTreeContextualMenu
Navigation/ HeaderNavItem, Breadcrumb, Tablist/{Root,Item}, Tree, Link, PageHeader
Data display/ Table, Card, Badge, EntityThumbnail, NodeIcon, FiltersBar
Feedback/ Message, Toast, Progress
-Patterns/ App, Login, Dashboard, Header, Aside, MenuBar, LivePreview
+Patterns/ App, Login, Dashboard, Header, Aside, Page, MenuBar, LivePreview
```
A new story picks an existing group; adding a group is a deliberate change to this file.
diff --git a/lib/Rozier/stories/RzActionsMenu.stories.ts b/lib/Rozier/stories/RzActionsMenu.stories.ts
index a690b3628..fa78f6261 100644
--- a/lib/Rozier/stories/RzActionsMenu.stories.ts
+++ b/lib/Rozier/stories/RzActionsMenu.stories.ts
@@ -1,18 +1,10 @@
import type { Meta, StoryObj } from '@storybook/html-vite'
import {
- type RzButtonOptions,
- rzButtonRenderer,
-} from '~/utils/component-renderer/rzButton'
+ type RzActionsMenuOptions,
+ rzActionsMenuRenderer,
+} from '~/utils/storybook/renderer/rzActionsMenu'
-const COMPONENT_CLASS_NAME = 'rz-actions-menu'
-
-type Args = {
- vertical?: boolean
- items: {
- popoverContent?: string
- button: RzButtonOptions
- }[]
-}
+type Args = RzActionsMenuOptions
const meta: Meta = {
title: 'Components/Actions/ActionsMenu',
@@ -91,32 +83,6 @@ const meta: Meta = {
export default meta
type Story = StoryObj
-function rzActionsMenuRenderer(args: Args) {
- const wrapper = document.createElement('div')
- wrapper.classList.add(COMPONENT_CLASS_NAME)
- if (args.vertical) {
- wrapper.classList.add(`${COMPONENT_CLASS_NAME}--vertical`)
- }
-
- args.items.forEach((item) => {
- const button = rzButtonRenderer(item.button)
-
- if (item.popoverContent) {
- const popoverWrapper = document.createElement('rz-popover')
- popoverWrapper.setAttribute('data-popover-offset', '24px')
- popoverWrapper.setAttribute('data-popover-placement', 'top-start')
- popoverWrapper.innerHTML = item.popoverContent
- popoverWrapper.appendChild(button)
-
- wrapper.appendChild(popoverWrapper)
- } else {
- wrapper.appendChild(button)
- }
- })
-
- return wrapper
-}
-
export const Default: Story = {}
export const Vertical: Story = {
diff --git a/lib/Rozier/stories/RzAside.stories.ts b/lib/Rozier/stories/RzAside.stories.ts
new file mode 100644
index 000000000..31e2dd3f1
--- /dev/null
+++ b/lib/Rozier/stories/RzAside.stories.ts
@@ -0,0 +1,111 @@
+import type { Meta, StoryObj } from '@storybook/html-vite'
+import { delay, http, HttpResponse } from 'msw'
+import {
+ type RzAsideOptions,
+ rzAsideRenderer,
+ rzAsideTreeWrapperRenderer,
+ TREE_TYPES,
+} from '~/utils/storybook/renderer/rzAside'
+
+type Args = RzAsideOptions
+
+const TREE_AJAX_GATEWAY = '/rz-admin/ajax/tree'
+
+// Read by the mocked gateway: rz-aside fetches its tree once connected
+let currentArgs: Args | null = null
+
+const meta: Meta = {
+ title: 'Patterns/Aside',
+ tags: ['autodocs'],
+ render: (args) => {
+ currentArgs = args
+ window.RozierConfig = {
+ ...(window.RozierConfig || {}),
+ ajaxToken: 'storybook-token',
+ routes: {
+ ...(window.RozierConfig?.routes || {}),
+ treeAjaxGateway: TREE_AJAX_GATEWAY,
+ },
+ }
+ const container = document.createElement('div')
+ container.style.cssText = 'width: 344px; height: 600px;'
+ const aside = rzAsideRenderer(args)
+ aside.style.height = '100%'
+ container.appendChild(aside)
+ return container
+ },
+ args: {
+ defaultTranslationId: '1',
+ treeType: 'node',
+ tree: {
+ items: [
+ {
+ label: 'Home',
+ iconClass: 'rz-icon-ri--home-2-fill',
+ expanded: true,
+ children: [
+ {
+ label: 'About us',
+ iconClass: 'rz-icon-rz--status-published-fill',
+ },
+ {
+ label: 'Contact',
+ iconClass: 'rz-icon-rz--status-draft-fill',
+ },
+ ],
+ },
+ {
+ label: 'Blog',
+ iconClass: 'rz-icon-rz--status-published-fill',
+ children: [
+ {
+ label: 'First post',
+ iconClass: 'rz-icon-rz--status-draft-fill',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ argTypes: {
+ treeType: { control: 'select', options: TREE_TYPES },
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get(TREE_AJAX_GATEWAY, () => {
+ const treeType = currentArgs?.treeType || 'node'
+ return HttpResponse.json({
+ tree_type: treeType,
+ [`${treeType}Tree`]: rzAsideTreeWrapperRenderer({
+ treeType,
+ tree: currentArgs?.tree,
+ }).outerHTML,
+ })
+ }),
+ ],
+ },
+ // One iframe per story: rz-aside uses a fixed ID
+ docs: { story: { inline: false, iframeHeight: 640 } },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+/** The gateway never answers: the loading state rendered by rz_aside.html.twig stays. */
+export const Loading: Story = {
+ parameters: {
+ controls: { include: [] },
+ msw: {
+ handlers: [
+ http.get(TREE_AJAX_GATEWAY, async () => {
+ await delay('infinite')
+ return HttpResponse.json({})
+ }),
+ ],
+ },
+ },
+}
diff --git a/lib/Rozier/stories/RzBulkActions.stories.ts b/lib/Rozier/stories/RzBulkActions.stories.ts
new file mode 100644
index 000000000..be2b115df
--- /dev/null
+++ b/lib/Rozier/stories/RzBulkActions.stories.ts
@@ -0,0 +1,47 @@
+import type { Meta, StoryObj } from '@storybook/html-vite'
+import {
+ DEFAULT_BULK_ACTIONS,
+ type RzBulkActionsOptions,
+ rzBulkActionsRenderer,
+} from '~/utils/storybook/renderer/rzBulkActions'
+
+type Args = RzBulkActionsOptions
+
+const meta: Meta = {
+ title: 'Components/Actions/BulkActions',
+ tags: ['autodocs'],
+ render: (args) => rzBulkActionsRenderer(args),
+ args: {
+ ...DEFAULT_BULK_ACTIONS,
+ // Hidden until rz-bulk-section selects an item, see Components/Actions/BulkSection
+ visible: true,
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const WithoutPanels: Story = {
+ args: {
+ actions: [
+ {
+ button: {
+ emphasis: 'primary',
+ color: 'success',
+ iconClass: 'rz-icon-ri--check-line',
+ attributes: { type: 'submit', title: 'Enable' },
+ },
+ },
+ {
+ button: {
+ emphasis: 'primary',
+ color: 'success',
+ iconClass: 'rz-icon-ri--close-line',
+ attributes: { type: 'submit', title: 'Disable' },
+ },
+ },
+ ],
+ },
+}
diff --git a/lib/Rozier/stories/RzBulkSection.stories.ts b/lib/Rozier/stories/RzBulkSection.stories.ts
new file mode 100644
index 000000000..33e7977f8
--- /dev/null
+++ b/lib/Rozier/stories/RzBulkSection.stories.ts
@@ -0,0 +1,48 @@
+import type { Meta, StoryObj } from '@storybook/html-vite'
+import { DEFAULT_BULK_ACTIONS } from '~/utils/storybook/renderer/rzBulkActions'
+import {
+ type RzBulkSectionOptions,
+ rzBulkSectionRenderer,
+} from '~/utils/storybook/renderer/rzBulkSection'
+
+type Args = RzBulkSectionOptions
+
+const meta: Meta = {
+ title: 'Components/Actions/BulkSection',
+ tags: ['autodocs'],
+ render: (args) => {
+ const section = rzBulkSectionRenderer(args)
+ // Show the JSON the element writes into the hidden input instead of submitting
+ const output = document.createElement('output')
+ section.addEventListener('submit', (event) => {
+ event.preventDefault()
+ const input = (event.target as HTMLFormElement).querySelector(
+ 'input.bulk-form-value',
+ ) as HTMLInputElement | null
+ output.textContent = `Submitted: ${input?.value || '(empty)'}`
+ })
+ section.appendChild(output)
+ return section
+ },
+ args: {
+ selectAllButton: true,
+ bulkActions: DEFAULT_BULK_ACTIONS,
+ items: [
+ { value: '1', label: 'Home' },
+ { value: '2', label: 'About us' },
+ { value: '3', label: 'Contact' },
+ { value: '4', label: 'Blog' },
+ ],
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const WithoutSelectAllButton: Story = {
+ args: {
+ selectAllButton: false,
+ },
+}
diff --git a/lib/Rozier/stories/RzGeotag.stories.ts b/lib/Rozier/stories/RzGeotag.stories.ts
new file mode 100644
index 000000000..ca69ee99e
--- /dev/null
+++ b/lib/Rozier/stories/RzGeotag.stories.ts
@@ -0,0 +1,61 @@
+import type { Meta, StoryObj } from '@storybook/html-vite'
+import 'leaflet/dist/leaflet.css' // The backoffice loads it through vendor.less
+import {
+ type RzGeotagOptions,
+ rzGeotagRenderer,
+} from '~/utils/storybook/renderer/rzGeotag'
+
+type Args = RzGeotagOptions
+
+const feature = (name: string, lng: number, lat: number) => ({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: [lng, lat] },
+ properties: { name },
+})
+
+const meta: Meta = {
+ title: 'Components/Form/Geotag',
+ tags: ['autodocs'],
+ render: (args) => {
+ window.RozierConfig = {
+ ...(window.RozierConfig || {}),
+ // roadiz_core.leafletMapTileUrl default
+ leafletMapTileUrl: 'https://{s}.tile.osm.org/{z}/{x}/{y}.png',
+ }
+ return rzGeotagRenderer(args)
+ },
+ args: {
+ id: 'geotag-default',
+ label: 'Location',
+ multiple: false,
+ value: JSON.stringify(feature('Lyon', 4.835659, 45.764043)),
+ help: 'Click the map or search a location to place the marker.',
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const Empty: Story = {
+ args: {
+ id: 'geotag-empty',
+ value: '',
+ },
+}
+
+export const Multiple: Story = {
+ args: {
+ id: 'geotag-multiple',
+ label: 'Locations',
+ multiple: true,
+ value: JSON.stringify({
+ type: 'FeatureCollection',
+ features: [
+ feature('Lyon', 4.835659, 45.764043),
+ feature('Paris', 2.352222, 48.856614),
+ ],
+ }),
+ },
+}
diff --git a/lib/Rozier/stories/RzHeaderNavItem.stories.ts b/lib/Rozier/stories/RzHeaderNavItem.stories.ts
index dad8b0bd2..e2a2804b8 100644
--- a/lib/Rozier/stories/RzHeaderNavItem.stories.ts
+++ b/lib/Rozier/stories/RzHeaderNavItem.stories.ts
@@ -38,7 +38,7 @@ export const ButtonCustomElement: Story = {
args: {
tag: 'button',
iconClass: 'rz-icon-ri--computer-line',
- attributes: { is: 'rz-header-nav-button' },
+ attributes: { is: 'rz-header-nav-item-button' },
},
parameters: {
controls: { exclude: ['tag'] },
diff --git a/lib/Rozier/stories/RzLivePreview.stories.ts b/lib/Rozier/stories/RzLivePreview.stories.ts
index 53e20c7b9..9cd0eb10e 100644
--- a/lib/Rozier/stories/RzLivePreview.stories.ts
+++ b/lib/Rozier/stories/RzLivePreview.stories.ts
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/html-vite'
import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+import { rzActionsMenuRenderer } from '~/utils/storybook/renderer/rzActionsMenu'
type Args = {
open: boolean
@@ -76,38 +77,42 @@ function rzLivePreviewRenderer(args: Args) {
// Same toggle as nodes/actionsMenu.html.twig, in the fixed actions menu.
// The save item keeps the menu visible below 960px, where the toggle is hidden.
- const actionsMenu = el(
- 'rz-actions-menu',
- 'rz-actions-menu rz-actions-menu--vertical actions-menu',
- )
- actionsMenu.append(
- rzButtonRenderer({
- emphasis: 'primary',
- size: 'md',
- onDark: true,
- iconClass: 'rz-icon-ri--eye-line',
- attributes: {
- type: 'button',
- class: 'rz-live-preview-toggle',
- title: 'Live preview',
- 'aria-label': 'Live preview',
- 'aria-controls': 'live-preview',
- 'aria-expanded': 'false',
+ const actionsMenu = rzActionsMenuRenderer({
+ vertical: true,
+ class: 'actions-menu',
+ items: [
+ {
+ button: {
+ emphasis: 'primary',
+ size: 'md',
+ onDark: true,
+ iconClass: 'rz-icon-ri--eye-line',
+ attributes: {
+ type: 'button',
+ class: 'rz-live-preview-toggle',
+ title: 'Live preview',
+ 'aria-label': 'Live preview',
+ 'aria-controls': 'live-preview',
+ 'aria-expanded': 'false',
+ },
+ },
},
- }),
- rzButtonRenderer({
- emphasis: 'primary',
- size: 'md',
- onDark: true,
- color: 'success',
- iconClass: 'rz-icon-ri--save-line',
- attributes: {
- type: 'button',
- title: 'Save',
- 'aria-label': 'Save',
+ {
+ button: {
+ emphasis: 'primary',
+ size: 'md',
+ onDark: true,
+ color: 'success',
+ iconClass: 'rz-icon-ri--save-line',
+ attributes: {
+ type: 'button',
+ title: 'Save',
+ 'aria-label': 'Save',
+ },
+ },
},
- }),
- )
+ ],
+ })
const preview = el('rz-live-preview', 'rz-live-preview')
preview.id = 'live-preview'
diff --git a/lib/Rozier/stories/RzNodeTreeContextualMenu.stories.ts b/lib/Rozier/stories/RzNodeTreeContextualMenu.stories.ts
new file mode 100644
index 000000000..ca40556ee
--- /dev/null
+++ b/lib/Rozier/stories/RzNodeTreeContextualMenu.stories.ts
@@ -0,0 +1,67 @@
+import type { Meta, StoryObj } from '@storybook/html-vite'
+import { http, HttpResponse } from 'msw'
+import { rzDropdownRenderer } from '~/utils/storybook/renderer/rzDropDown'
+import {
+ DEFAULT_CONTEXTUAL_MENU_CONTENT,
+ POPOVER_PLACEMENTS,
+ type RzNodeTreeContextualMenuOptions,
+ rzNodeTreeContextualMenuRenderer,
+} from '~/utils/storybook/renderer/rzNodeTreeContextualMenu'
+
+type Args = RzNodeTreeContextualMenuOptions
+
+const CONTEXTUAL_MENU_PATH = '/rz-admin/nodes/1/contextual-menu'
+const UPDATE_PATHS = {
+ statusPath: '/rz-admin/ajax/nodes/statuses',
+ duplicatePath: '/rz-admin/ajax/nodes/duplicate',
+ pastePath: '/rz-admin/ajax/nodes/paste',
+ editPositionPath: '/rz-admin/ajax/nodes/position',
+}
+
+const meta: Meta = {
+ title: 'Components/Overlay/NodeTreeContextualMenu',
+ tags: ['autodocs'],
+ render: (args) => rzNodeTreeContextualMenuRenderer(args),
+ args: {
+ nodeId: '1',
+ contextualMenuPath: CONTEXTUAL_MENU_PATH,
+ copiedTrans: 'Node copied to clipboard',
+ popoverPlacement: 'bottom-end',
+ ...UPDATE_PATHS,
+ },
+ argTypes: {
+ popoverPlacement: {
+ control: 'select',
+ options: POPOVER_PLACEMENTS,
+ },
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ http.get(CONTEXTUAL_MENU_PATH, () =>
+ HttpResponse.html(
+ rzDropdownRenderer(DEFAULT_CONTEXTUAL_MENU_CONTENT)
+ .outerHTML,
+ ),
+ ),
+ ...Object.values(UPDATE_PATHS).map((path) =>
+ http.post(path, () => HttpResponse.json({})),
+ ),
+ ],
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+/** As singleNode.html.twig renders it: the element fetches contextualMenu.html.twig (mocked) on first opening. */
+export const Default: Story = {}
+
+/** Story-only: the popover content is prefilled, so its items follow the `content` control. */
+export const Prefilled: Story = {
+ args: {
+ content: DEFAULT_CONTEXTUAL_MENU_CONTENT,
+ },
+}
diff --git a/lib/Rozier/stories/RzPage.stories.ts b/lib/Rozier/stories/RzPage.stories.ts
new file mode 100644
index 000000000..729959109
--- /dev/null
+++ b/lib/Rozier/stories/RzPage.stories.ts
@@ -0,0 +1,50 @@
+import type { Meta, StoryObj } from '@storybook/html-vite'
+import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
+import {
+ type RzPageOptions,
+ rzPageRenderer,
+} from '~/utils/storybook/renderer/rzPage'
+
+type Args = RzPageOptions
+
+const PLACEHOLDER_CONTENT = Array(12)
+ .fill(
+ '',
+ )
+ .join('')
+
+const meta: Meta = {
+ title: 'Patterns/Page',
+ tags: ['autodocs'],
+ render: (args) => {
+ const page = rzPageRenderer(args)
+ // The back-to-top button of base.html.twig is hidden: any button can send --scroll-top
+ page.querySelector('#main-content-scrollable')?.appendChild(
+ rzButtonRenderer({
+ label: 'Scroll to top',
+ iconClass: 'rz-icon-ri--arrow-up-line',
+ emphasis: 'secondary',
+ attributes: {
+ type: 'button',
+ command: '--scroll-top',
+ commandfor: args.id || 'rz-page',
+ },
+ }),
+ )
+ return page
+ },
+ args: {
+ id: 'rz-page',
+ content: PLACEHOLDER_CONTENT,
+ },
+ parameters: {
+ layout: 'fullscreen',
+ // One iframe per story: the instances would share the rz-page ID on the docs page
+ docs: { story: { inline: false, iframeHeight: 600 } },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
diff --git a/lib/Rozier/stories/RzSaveButton.stories.ts b/lib/Rozier/stories/RzSaveButton.stories.ts
new file mode 100644
index 000000000..d4a09ab04
--- /dev/null
+++ b/lib/Rozier/stories/RzSaveButton.stories.ts
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from '@storybook/html-vite'
+import { rzActionsMenuRenderer } from '~/utils/storybook/renderer/rzActionsMenu'
+import {
+ type RzSaveButtonOptions,
+ rzSaveButtonRenderer,
+} from '~/utils/storybook/renderer/rzSaveButton'
+
+type Args = RzSaveButtonOptions
+
+const FORM_ID = 'rz-save-button-story-form'
+
+const meta: Meta = {
+ title: 'Components/Actions/SaveButton',
+ tags: ['autodocs'],
+ render: (args) => {
+ const wrapper = document.createElement('div')
+
+ const form = document.createElement('form')
+ form.id = FORM_ID
+ form.innerHTML = `
+`
+ form.addEventListener('submit', (event) => {
+ event.preventDefault()
+ form.querySelector('output')!.textContent =
+ `Submitted at ${new Date().toLocaleTimeString()}`
+ })
+ wrapper.appendChild(form)
+
+ // The button moves itself into the first .rz-actions-menu of the document
+ wrapper.appendChild(rzActionsMenuRenderer({ items: [] }))
+
+ wrapper.appendChild(rzSaveButtonRenderer(args))
+
+ return wrapper
+ },
+ args: {
+ dataActionSave: `#${FORM_ID}`,
+ label: 'Save',
+ },
+ argTypes: {
+ dataActionSave: {
+ description:
+ 'Selector of the form submitted on click and on Cmd/Ctrl+S.',
+ },
+ },
+ parameters: {
+ // One iframe per story: the button binds to the first actions menu of the document
+ docs: { story: { inline: false, iframeHeight: 300 } },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
diff --git a/lib/Rozier/stories/RzTree.stories.ts b/lib/Rozier/stories/RzTree.stories.ts
index 3b8edf854..f70c44cd2 100644
--- a/lib/Rozier/stories/RzTree.stories.ts
+++ b/lib/Rozier/stories/RzTree.stories.ts
@@ -1,33 +1,17 @@
import type { Meta, StoryObj } from '@storybook/html-vite'
-import { rzBadgeRenderer } from '~/utils/component-renderer/rzBadge'
-import { rzButtonRenderer } from '~/utils/component-renderer/rzButton'
-import { rzPopoverRenderer } from '~/utils/storybook/renderer/rzPopover'
-import { rzDropdownRenderer } from '~/utils/storybook/renderer/rzDropDown'
import { rzNodeIconRenderer } from '~/utils/storybook/renderer/rzNodeIcon'
+import {
+ rzNodeTreeContextualMenuRenderer,
+ DEFAULT_CONTEXTUAL_MENU_CONTENT,
+} from '~/utils/storybook/renderer/rzNodeTreeContextualMenu'
+import {
+ COMPONENT_CLASS_NAME,
+ type RzTreeOptions,
+ rzTreeRenderer,
+} from '~/utils/storybook/renderer/rzTree'
+
+type Args = RzTreeOptions
-type Item = {
- label: string
- iconClass?: string
- icon?: HTMLElement
- href?: string
- expanded?: boolean
- children?: Item[]
- actions?: HTMLElement[]
- /** Stack tree only: tags fold into a popover past MAX_VISIBLE_TAGS or when the row is narrow. */
- tags?: string[]
-}
-
-/** Mirrors `visibleCount` in widgets/nodeTree/singleNode.html.twig — keep both in step. */
-const MAX_VISIBLE_TAGS = 6
-
-type Args = {
- items: Item[]
- iconColor?: string
- /** Adds rz-tree--child-nodes, the layout of the children nodes tree. */
- childNodes?: boolean
-}
-
-const COMPONENT_CLASS_NAME = 'rz-tree'
const meta: Meta = {
title: 'Components/Navigation/Tree',
tags: ['autodocs'],
@@ -41,172 +25,6 @@ const meta: Meta = {
export default meta
type Story = StoryObj
-function itemNodeRenderer(item: Item) {
- const hasChildren = item.children && item.children.length > 0
- const tag = item.href ? 'a' : 'div'
-
- const node = document.createElement(tag)
- node.setAttribute('role', 'treeitem')
- node.setAttribute('aria-expanded', item.expanded ? 'true' : 'false')
- node.classList.add(`${COMPONENT_CLASS_NAME}__item__node`)
-
- const innerEl = document.createElement('div')
- innerEl.classList.add(`${COMPONENT_CLASS_NAME}__item__node__inner`)
- node.appendChild(innerEl)
-
- const handle = document.createElement('span')
- handle.classList.add(`${COMPONENT_CLASS_NAME}__item__handle`)
- handle.classList.add('rz-icon-ri--draggable')
- innerEl.appendChild(handle)
-
- // Icon slot - use custom element if provided, otherwise use icon class
- if (item.icon) {
- item.icon.classList.add(`${COMPONENT_CLASS_NAME}__item__icon`)
- innerEl.appendChild(item.icon)
- } else if (item.iconClass) {
- const icon = document.createElement('span')
- icon.classList.add(`${COMPONENT_CLASS_NAME}__item__icon`)
- icon.classList.add(item.iconClass)
- innerEl.appendChild(icon)
- }
-
- const label = document.createElement('span')
- label.classList.add(`${COMPONENT_CLASS_NAME}__item__label`)
- label.textContent = item.label
- innerEl.appendChild(label)
-
- if (item.tags && item.tags.length > 0) {
- innerEl.appendChild(tagsRenderer(item.tags))
- }
-
- if (hasChildren) {
- const expandButton = rzButtonRenderer({
- tag: 'span',
- iconClass: 'rz-icon-ri--arrow-down-s-line',
- emphasis: 'tertiary',
- size: 'xs',
- })
- expandButton.classList.add(
- `${COMPONENT_CLASS_NAME}__item__expand-button`,
- )
-
- innerEl.appendChild(expandButton)
- }
-
- // Actions slot - populated from item.actions
- if (item.actions && item.actions.length > 0) {
- const actionsSlot = document.createElement('div')
- actionsSlot.classList.add(`${COMPONENT_CLASS_NAME}__item__actions`)
- item.actions.forEach((action) => actionsSlot.appendChild(action))
- innerEl.appendChild(actionsSlot)
- }
-
- return node
-}
-
-/** Mirrors what rz_overflow_list.html.twig emits for singleNode.html.twig with `responsive`. */
-function tagsRenderer(tags: string[]) {
- const badge = (tag: string) =>
- rzBadgeRenderer({ tag: 'rz-badge', label: tag, size: 'sm' })
- const inline = tags.slice(0, MAX_VISIBLE_TAGS)
- const folded = tags.slice(MAX_VISIBLE_TAGS)
-
- const wrapper = document.createElement('div')
- wrapper.className = `rz-overflow-list ${COMPONENT_CLASS_NAME}__item__tags`
- inline.forEach((tag) => wrapper.appendChild(badge(tag)))
-
- const list = document.createElement('div')
- list.className = 'rz-dropdown__list rz-overflow-list__list'
- folded.forEach((tag) => list.appendChild(badge(tag)))
- const content = document.createElement('div')
- content.className = `rz-dropdown rz-overflow-list__popover ${COMPONENT_CLASS_NAME}__item__tags__popover`
- content.appendChild(list)
-
- const { popover } = rzPopoverRenderer({
- targetElement: {
- element: rzButtonRenderer({
- iconClass: 'rz-icon-ri--price-tag-3-line',
- emphasis: 'secondary',
- size: 'sm',
- label: folded.length ? String(folded.length) : undefined,
- attributes: {
- type: 'button',
- 'aria-label': 'More tags',
- class: 'rz-overflow-list__button',
- },
- }),
- },
- popoverElement: {
- element: content,
- id: `rz-tree-tags-${Math.random().toString(36).slice(2, 8)}`,
- },
- offset: 8,
- })
- popover.setAttribute('overflow-responsive', '')
- popover.setAttribute('overflow-inline', String(inline.length))
- popover.setAttribute('overflow-inline-side', 'before')
- popover.setAttribute('overflow-count', '')
- popover.hidden = folded.length === 0
- wrapper.appendChild(popover)
-
- return wrapper
-}
-
-function itemRenderer(item: Item) {
- const ITEM_LIST_CLASS = 'rz-tree-item'
-
- const li = document.createElement('li', { is: ITEM_LIST_CLASS })
- li.setAttribute('is', ITEM_LIST_CLASS)
- li.classList.add(`${COMPONENT_CLASS_NAME}__item`)
-
- const content = itemNodeRenderer(item)
- li.appendChild(content)
-
- if (item.children) {
- li.classList.add(`${COMPONENT_CLASS_NAME}__item--parent`)
-
- const list = listRenderer(item.children)
- list.setAttribute('role', 'group')
- li.appendChild(list)
- } else {
- li.classList.add(`${COMPONENT_CLASS_NAME}__item--end`)
- }
-
- return li
-}
-
-function listRenderer(items: Item[]) {
- const element = document.createElement('ul')
-
- element.classList.add(`${COMPONENT_CLASS_NAME}__list`)
-
- items.forEach((item) => {
- const itemEl = itemRenderer(item)
- element.appendChild(itemEl)
- })
-
- return element
-}
-
-function rzTreeRenderer(args: Args) {
- const element = document.createElement('rz-tree')
- element.classList.add(COMPONENT_CLASS_NAME)
-
- const list = listRenderer(args.items)
- list.setAttribute('role', 'tree')
- element.appendChild(list)
-
- if (args.iconColor) {
- element.style.setProperty('--rz-tree-icon-color', args.iconColor)
- }
-
- if (args.childNodes) {
- element.classList.add(`${COMPONENT_CLASS_NAME}--child-nodes`)
- }
-
- return element
-}
-
export const Default: Story = {
args: {
items: [
@@ -389,103 +207,14 @@ export const ChildNodes: Story = {
},
}
-/**
- * Generates a unique ID for contextual menu instances
- */
-let contextualMenuCounter = 0
-function generateContextualMenuId() {
- return `node-contextual-menu-${++contextualMenuCounter}`
-}
-
-/**
- * Creates a RzNodeTreeContextualMenu element with a popover trigger button
- */
function createContextualMenuAction(): HTMLElement {
- const contextualId = generateContextualMenuId()
- const popoverId = `${contextualId}-popover`
-
- // Create the contextual menu wrapper element
- const contextualMenu = document.createElement(
- 'rz-node-tree-contextual-menu',
- )
- contextualMenu.classList.add('rz-node-contextual-menu')
- contextualMenu.setAttribute('id', contextualId)
- contextualMenu.setAttribute('popover-placement', 'bottom-end')
- // These paths won't work in Storybook but are included for demonstration
- contextualMenu.setAttribute('data-node-id', '1')
- contextualMenu.setAttribute('data-contextual-menu-path', '#')
- contextualMenu.setAttribute('data-node-status-path', '#')
- contextualMenu.setAttribute('data-node-duplicate-path', '#')
- contextualMenu.setAttribute('data-node-paste-path', '#')
- contextualMenu.setAttribute(
- 'data-node-copied-trans',
- 'Node copied to clipboard',
- )
- contextualMenu.setAttribute('data-node-edit-position-path', '#')
-
- // Create the trigger button
- const triggerButton = rzButtonRenderer({
- iconClass: 'rz-icon-ri--more-line',
- emphasis: 'tertiary',
- size: 'xs',
- attributes: {
- 'aria-label': 'Show actions',
- popovertarget: popoverId,
- type: 'button',
- },
+ // Prefilled content: the contextual menu path does not exist in Storybook
+ return rzNodeTreeContextualMenuRenderer({
+ nodeId: '1',
+ contextualMenuPath: '#',
+ copiedTrans: 'Node copied to clipboard',
+ content: DEFAULT_CONTEXTUAL_MENU_CONTENT,
})
- contextualMenu.appendChild(triggerButton)
-
- // Create the popover placeholder (content is fetched on open)
- const popoverPlaceholder = document.createElement('div')
- popoverPlaceholder.id = popoverId
- popoverPlaceholder.setAttribute('popover', '')
- popoverPlaceholder.setAttribute('data-popover-content-state', 'idle')
- popoverPlaceholder.setAttribute('data-contextual-menu-popover', '')
-
- // Use rzDropdownRenderer for popover content
- const dropdown = rzDropdownRenderer(
- {
- title: 'Actions',
- items: [
- [
- {
- tag: 'button',
- iconClass: 'rz-icon-ri--file-copy-line',
- label: 'Duplicate',
- attributes: { command: '--duplicate' },
- },
- {
- tag: 'button',
- iconClass: 'rz-icon-ri--clipboard-line',
- label: 'Copy',
- attributes: { command: '--copy' },
- },
- ],
- [
- {
- tag: 'button',
- iconClass: 'rz-icon-ri--arrow-up-line',
- label: 'Move to first',
- attributes: { command: '--move-first' },
- },
- {
- tag: 'button',
- iconClass: 'rz-icon-ri--arrow-down-line',
- label: 'Move to last',
- attributes: { command: '--move-last' },
- },
- ],
- ],
- },
- popoverPlaceholder,
- )
- dropdown.classList.add('rz-dropdown')
-
- popoverPlaceholder.setAttribute('data-popover-content-state', 'fetched')
- contextualMenu.appendChild(popoverPlaceholder)
-
- return contextualMenu
}
export const WithContextualMenu: Story = {