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 = {