role( 'essential' )). assets/guidance.js does the rest * as a progressive enhancement of the help the field rows and the cards already print: the info icon after a field's * label, after a card's or a section's title, after the page's h1; the toggletip (FR6) for every one of them. * * Tables (FR17, CONTRACT §8.2): the Columns chooser, the export pair, the row buttons of an actions column; Ui::table() * calls them for its `chooser` and `export` options and for an actions column's items. * * Escaped HTML, as every builder; echo through Markup::out(). * * @package RoosterAdmin */ namespace Rooster\Admin; defined( 'ABSPATH' ) || exit; /** * Static. */ final class Guidance { /** * The script's handle (assets/guidance.js). */ public const HANDLE = 'rooster-admin-guidance'; /** * The roles a text may have. */ public const ROLES = array( 'essential', 'guidance', 'reference' ); /** * A table's id, as the `tables` preference keys it (a DOM id). */ public const TABLE_PATTERN = '/^[A-Za-z][A-Za-z0-9_-]{0,119}$/'; /** * A column's key in the `tables` preference. */ public const KEY_PATTERN = '/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,59}$/'; /** * Most columns one table's choice holds. */ public const MAX_SHOWN = 60; /** * The highest place (the cell's position in its row, the selection's included) the Columns chooser can hide: the * stylesheet has one rule per place up to it (kit.css, "The Columns chooser"). A column further right stays shown. */ public const HIDE_MAX = 32; /* ── The level ─────────────────────────────────────────────────────── */ /** * The current person's level: 'guided' or 'compact'. */ public static function level(): string { return Prefs::guidance( get_current_user_id() ); } /** * Filter admin_body_class: Compact as a body class on the chrome's screens, so the first paint hides what the * level hides (the stylesheet does it only under WordPress's `js` class: without a script, nothing is lost). * * @param mixed $classes Space-separated classes. */ public static function body_class( $classes ): string { $classes = is_string( $classes ) ? $classes : ''; $registry = Library::registry(); if ( null === $registry || '' === $registry->current_product() || 'compact' !== self::level() ) { return $classes; } return $classes . ' rooster-admin-guidance-compact'; } /** * Hook admin_enqueue_scripts (21): the guidance script on every chrome screen (its switch is in the brand bar), * after the chrome's script, whose saving and live region it uses; never elsewhere. */ public static function enqueue(): void { $registry = Library::registry(); if ( null === $registry || '' === $registry->current_product() ) { return; } wp_enqueue_script( self::HANDLE, Library::asset( 'assets/guidance' . Library::suffix() . '.js' ), array( Library::HANDLE ), Library::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( self::HANDLE, 'var roosterAdminGuidance = ' . wp_json_encode( self::script_data(), JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' ); } /** * The script's data: the level and the words. * * @return array */ public static function script_data(): array { return array( 'level' => self::level(), 'l10n' => array( 'switch' => self::labels(), /* translators: %s: what the tip explains (a field's label, a card's title). */ 'about' => __( 'About %s', 'ai-eshop-optimizer' ), 'why' => __( 'Why?', 'ai-eshop-optimizer' ), 'guided' => __( 'Guided: every explanation shown', 'ai-eshop-optimizer' ), 'compact' => __( 'Compact: explanations as info icons', 'ai-eshop-optimizer' ), ), ); } /** * The brand bar's switch's words: its name, and its tip by level (what it is, what a click makes it). * * @return array */ public static function labels(): array { return array( 'name' => __( 'Compact explanations', 'ai-eshop-optimizer' ), 'guided' => __( 'Explanations: shown in full. Switch to compact', 'ai-eshop-optimizer' ), 'compact' => __( 'Explanations: compact, as info icons. Switch to guided', 'ai-eshop-optimizer' ), ); } /** * The brand bar's guidance switch (FR19: the person's, beside light and dark, one preference for every product): a * toggle button, pressed in Compact. Chrome's brand bar prints it before the theme toggle. */ public static function button(): string { $level = self::level(); $compact = 'compact' === $level; $labels = self::labels(); Icons::svg( $compact ? 'message-square-text' : 'info' ); // both symbols in the page's sprite: the switch swaps them, Compact's icons use `info` return '
  • '; } /* ── Text roles ────────────────────────────────────────────────────── */ /** * A field row's role (the row's `attrs`): `Ui::field_row( array( …, 'help' => …, 'attrs' => Guidance::role( * 'essential' ) ) )` keeps its help in Compact (a legal duty, data loss, money); 'reference' hides it there with * no icon (its "Learn more" link, if the help holds one, stays beside the label). A row without a role is guidance. * * @param string $role essential, guidance or reference. * @return array */ public static function role( string $role ): array { return array( 'data-ra-role' => in_array( $role, self::ROLES, true ) ? $role : 'guidance' ); } /** * A text with its role (UI-Design-Patterns §5): essential, always shown; guidance, shown in Guided and a "Why?" * toggletip in its place in Compact; reference, shown in Guided only, its "Learn more" link in both. One block in * a card's body rhythm. * * @param string $text The text (raw). * @param string $role essential, guidance (the default) or reference. * @param array $opts html (escaped inline HTML in place of the text: , , a link; never a * control, FR5), id (the text's id; the "Why?" button is described by it), more * (the manual's URL: a "Learn more" link), more_label (raw), about (raw: what it * explains, for the "Why?" button's name: "Why? "), question (raw, 0.68.21: * the button's words in place of "Why?", the question this text answers; give * one to each where a card holds more than one guidance text, the owner: two * "Why?" in a row that answered "What it does" and "How to set up"). Without * one, a text whose html opens with a bold lead-in (`How to set * up. …`) takes the lead-in as the button's words, its stop left out. */ public static function text( string $text, string $role = 'guidance', array $opts = array() ): string { $role = in_array( $role, self::ROLES, true ) ? $role : 'guidance'; $id = (string) ( $opts['id'] ?? wp_unique_id( 'rooster-admin-guidance-' ) ); $inner = '' !== (string) ( $opts['html'] ?? '' ) ? (string) $opts['html'] : esc_html( $text ); $more = '' !== (string) ( $opts['more'] ?? '' ) ? ' ' . self::learn_more( (string) $opts['more'], array( 'label' => (string) ( $opts['more_label'] ?? '' ) ) ) : ''; $why = ''; if ( 'guidance' === $role ) { $about = (string) ( $opts['about'] ?? '' ); $ask = (string) ( $opts['question'] ?? '' ); if ( '' === $ask && preg_match( '#^\s*<(strong|b)>([^<]{1,60})#u', $inner, $lead ) ) { $ask = rtrim( trim( html_entity_decode( $lead[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ), '.:· ' ); } $ask = '' !== $ask ? $ask : __( 'Why?', 'ai-eshop-optimizer' ); $why = ''; } return '
    ' . $why . '' . $inner . '' . $more . '
    '; } /** * A tip (FR6): an info button after what it explains (a label, a title, a figure), its text in a popover a hover * or the keyboard's focus shows and a click or a tap pins, Escape closes; the button is named "About " and * described by the text. Shown in both levels (a tip is not a guidance text Compact folds: it is one already). * Never in an actions column or among a row's buttons (P11). * * @param string $text The tip (raw). * @param string $about What it explains (raw): the button's name is "About ". * @param array $opts id (the text's id). */ public static function tip( string $text, string $about, array $opts = array() ): string { $id = (string) ( $opts['id'] ?? wp_unique_id( 'rooster-admin-tip-' ) ); /* translators: %s: what the tip explains. */ $name = sprintf( __( 'About %s', 'ai-eshop-optimizer' ), $about ); return '' . ''; } /** * A "Learn more" link to the manual (a reference text's, in both levels): a new tab, said so. * * @param string $url The manual's URL. * @param array $opts label (raw; "Learn more" by default). */ public static function learn_more( string $url, array $opts = array() ): string { $label = '' !== (string) ( $opts['label'] ?? '' ) ? (string) $opts['label'] : __( 'Learn more', 'ai-eshop-optimizer' ); return '' . esc_html( $label ) . Icons::ui( 'external', 13 ) . ' ' . esc_html__( '(opens in a new tab)', 'ai-eshop-optimizer' ) . ''; } /* ── Tables (FR17) ─────────────────────────────────────────────────── */ /** * A row's buttons for the table's actions column (FR17: one recipe): small outline buttons, each named with its * row ("Edit Hair basics"), the frequent first as given, the destructive last, one group that never wraps (P11). * Ui::table() calls it for an `actions` column whose cell is a list of items. * * @param array> $items {label (raw), url (a link) or none (a button a script answers), * destructive, icon (Lucide), disabled, attrs; 0.63.1: description * (raw: what the action does, in the chrome's tip and read with the * button; a help among the buttons would break their line, P11), reason * (raw: why it cannot act now, visible after it: FR7; disables it)}. * @param string $row The row's name (raw): what each button acts on. */ public static function row_buttons( array $items, string $row ): string { $items = array_values( array_filter( $items, static fn( $item ): bool => is_array( $item ) && '' !== (string) ( $item['label'] ?? '' ) ) ); usort( $items, static fn( array $a, array $b ): int => (int) ! empty( $a['destructive'] ) <=> (int) ! empty( $b['destructive'] ) ); // stable since PHP 8.0: the given order kept within each kind $html = ''; foreach ( $items as $item ) { $label = (string) $item['label']; $attrs = ( is_array( $item['attrs'] ?? null ) ? $item['attrs'] : array() ) + ( '' !== $row ? array( 'aria-label' => $label . ' ' . $row ) : array() ); $about = (string) ( $item['description'] ?? '' ); if ( '' !== $about ) { $attrs += array( 'data-ra-tip' => $about, 'aria-description' => $about, ); } $html .= Ui::button( $label, array( 'size' => 'sm', // outline, the destructive one in the destructive ink: a red fill on every row would shout 'class' => ! empty( $item['destructive'] ) ? 'is-destructive' : '', 'icon' => (string) ( $item['icon'] ?? '' ), 'href' => (string) ( $item['url'] ?? '' ), 'disabled' => ! empty( $item['disabled'] ), 'reason' => (string) ( $item['reason'] ?? '' ), 'attrs' => $attrs, ) ); } return '' === $html ? '' : '' . $html . ''; } /** * The Columns chooser of a wide table (FR17): a button "Columns 8 of 24" opening the list of the table's columns, * each a checkbox; the first column (the row's name) and any column marked `always` stay shown. A change shows or * hides the column at once and is the person's (Prefs::TABLES, per product and table); "Reset to defaults" brings * back the product's set. Ui::table() prints it for its `chooser` option, in the toolbar's end; a product may print * it elsewhere (a result head) with the same table id and columns. * * @param string $table The table's DOM id. * @param array> $columns The table's columns (Ui::table()'s). * @param array $opts shown (the keys shown now: Guidance::shown()), selectable (the * table has a selection column), offset (0.65.0: the cells before * the first column's, over selectable), label (raw: the list's * name). */ public static function columns( string $table, array $columns, array $opts = array() ): string { $shown = array_map( 'strval', (array) ( $opts['shown'] ?? array_column( $columns, 'key' ) ) ); $default = array_map( 'strval', (array) ( $opts['default'] ?? $shown ) ); $offset = isset( $opts['offset'] ) ? max( 0, (int) $opts['offset'] ) : ( ! empty( $opts['selectable'] ) ? 1 : 0 ); // 0.65.0: every cell before the first column's (a grip, a checkbox, a Details button) $total = count( $columns ); $on = 0; $checks = ''; foreach ( array_values( $columns ) as $i => $col ) { $key = (string) ( $col['key'] ?? '' ); $place = $i + 1 + $offset; $fixed = 0 === $i || ! empty( $col['always'] ) || $place > self::HIDE_MAX; $is_on = $fixed || in_array( $key, $shown, true ); $on += $is_on ? 1 : 0; $why = $fixed ? $table . '-always-' . $place : ''; // FR7 (R2): a column that cannot be hidden says so $checks .= ''; } /* translators: 1: the columns shown, 2: the table's columns. */ $count = __( 'Columns %1$s of %2$s', 'ai-eshop-optimizer' ); $label = (string) ( $opts['label'] ?? __( 'Columns of the table', 'ai-eshop-optimizer' ) ); $panel = $table . '-columns'; // A popover (the top layer: a card is a size container, which would trap and cover a panel of its own); without // the script it opens in the window's middle, with it under its button (table.js). return '
    ' . '' . '
    ' . esc_html__( 'Columns shown', 'ai-eshop-optimizer' ) . '' . Ui::button( __( 'Reset to defaults', 'ai-eshop-optimizer' ), array( 'variant' => 'ghost', 'size' => 'sm', 'attrs' => array( 'data-ra-columns-reset' => '' ) ) ) . '
    ' . '
    ' . $checks . '
    '; } /** * The keys a table shows now: the person's choice (Prefs::TABLES) of the table's columns, else the product's * default set (every column when none is named). * * @param string $table The table's DOM id. * @param array> $columns The table's columns. * @param array|null $default The product's default set (null: every column). * @return list */ public static function shown( string $table, array $columns, ?array $default = null ): array { $keys = array_values( array_map( 'strval', array_column( $columns, 'key' ) ) ); $default = null === $default ? $keys : array_values( array_intersect( $keys, array_map( 'strval', $default ) ) ); $registry = Library::registry(); $product = null !== $registry ? $registry->current_product() : ''; $user = get_current_user_id(); if ( '' === $product || $user <= 0 || 1 !== preg_match( self::TABLE_PATTERN, $table ) ) { return $default; } $saved = Prefs::get( $user, Prefs::TABLES, $product, array() ); $mine = is_array( $saved ) && is_array( $saved[ $table ] ?? null ) ? self::clean_shown( $saved[ $table ] ) : null; return null === $mine ? $default : array_values( array_intersect( $keys, $mine ) ); } /** * A `tables` value as the route takes it: a list of column keys, or null when it is not one. * * @param mixed $value The decoded value. * @return list|null */ public static function clean_shown( $value ): ?array { if ( ! is_array( $value ) || count( $value ) > self::MAX_SHOWN || array_values( $value ) !== $value ) { return null; } $out = array(); foreach ( $value as $key ) { if ( ! is_string( $key ) || 1 !== preg_match( self::KEY_PATTERN, $key ) ) { return null; } if ( ! in_array( $key, $out, true ) ) { $out[] = $key; } } return $out; } /** * A product's table choices with one table's set (null forgets it), the table saved last at the end, the oldest * dropped while the whole is over its budget (Prefs::LIMITS). * * @param array $tables The product's choices: table id => keys. * @param string $table The table's DOM id. * @param array|null $keys Its shown keys, or null. * @return array> */ public static function store( array $tables, string $table, ?array $keys ): array { $out = array(); foreach ( $tables as $id => $set ) { $clean = is_string( $id ) && 1 === preg_match( self::TABLE_PATTERN, $id ) && $id !== $table ? self::clean_shown( $set ) : null; if ( null !== $clean ) { $out[ $id ] = $clean; } } if ( null !== $keys ) { $out[ $table ] = array_values( $keys ); } while ( count( $out ) > 1 && strlen( (string) wp_json_encode( $out ) ) > ( Prefs::LIMITS[ Prefs::TABLES ] ?? Prefs::MAX_BYTES ) ) { array_shift( $out ); } return $out; } /** * The export pair of a report (FR17): CSV and XLSX side by side, small outline buttons with the download icon, * their scope in the tip and the description ("Exports every matching row, every column"); disabled with its reason * as visible text after them when there is nothing to export (FR7). Ui::table() prints it for its `export` option * in the toolbar's end; a product may print it elsewhere (a result head). * * @param array $opts csv, xlsx (each a URL, or '' for a button the product's script answers: * `data-ra-export="csv"`), scope (raw: what the files hold), reason (raw: why * there is nothing to export; disables both), id (the scope's id), attrs (on * both: a script's hooks); picked (0.63.0, raw with %s: what the files hold * while rows are ticked, "Exports the 5 rows you ticked": in a local or remote * table the links then carry the ticked rows' ids, CONTRACT §8.2). 0.76.0 (CONTRACT * §8.5 "The export"), each opt-in: columns ('visible': the files follow the * person's Columns choice; the pair names the parameter, * `data-ra-export-columns="columns"`, and table.js puts the shown keys * there in their order, Export::request() reads them), views ('sheets': the XLSX * holds every view of the table's view pills, one sheet each; its link carries * `sheets=1`, the pair names it, `data-ra-export-views`, and the glue drops * it while rows are ticked; the CSV stays the view), prefix (the table's: the * parameters' names; Ui::table() passes it), shown (the keys the table shows now, * when the Columns chooser hides some: with `columns`, the links carry them from * the first paint), sheets (raw: the XLSX's words with views as sheets, "Every * view, one sheet each", in its tip and its description). */ public static function export( array $opts ): string { $id = (string) ( $opts['id'] ?? wp_unique_id( 'rooster-admin-export-' ) ); $scope = (string) ( $opts['scope'] ?? '' ); $reason = (string) ( $opts['reason'] ?? '' ); $prefix = (string) preg_replace( '/[^A-Za-z0-9_-]/', '', (string) ( $opts['prefix'] ?? '' ) ); $cols = 'visible' === ( $opts['columns'] ?? '' ) ? $prefix . 'columns' : ''; // the names are Export::KEYS' words after the table's prefix $sheets = 'sheets' === ( $opts['views'] ?? '' ) ? $prefix . 'sheets' : ''; $shown = '' !== $cols && is_array( $opts['shown'] ?? null ) ? implode( ',', array_filter( array_map( 'strval', $opts['shown'] ), static fn( string $key ): bool => 1 === preg_match( self::KEY_PATTERN, $key ) ) ) : ''; $words = '' !== $sheets ? (string) ( $opts['sheets'] ?? __( 'Every view, one sheet each', 'ai-eshop-optimizer' ) ) : ''; $html = ''; foreach ( array( 'csv' => __( 'Export CSV', 'ai-eshop-optimizer' ), 'xlsx' => __( 'Export XLSX', 'ai-eshop-optimizer' ) ) as $format => $label ) { $url = (string) ( $opts[ $format ] ?? '' ); $own = 'xlsx' === $format && '' !== $words; // the XLSX holds every view: its own words after the scope $url = '' !== $url && '' !== $shown ? add_query_arg( $cols, rawurlencode( $shown ), $url ) : $url; $url = '' !== $url && $own ? add_query_arg( $sheets, '1', $url ) : $url; $attrs = ( is_array( $opts['attrs'] ?? null ) ? $opts['attrs'] : array() ) + array( 'data-ra-export' => $format ); if ( '' !== $scope || $own ) { $attrs['data-ra-tip'] = trim( $scope . ( $own ? ' ' . $words : '' ) ); } $descr = trim( ( '' !== $scope ? $id . '-scope' : '' ) . ( $own ? ' ' . $id . '-sheets' : '' ) . ( '' !== $reason ? ' ' . $id . '-why' : '' ) ); if ( '' !== $descr ) { $attrs['aria-describedby'] = $descr; } $html .= Ui::button( $label, array( 'size' => 'sm', 'icon' => 'download', 'href' => '' !== $reason ? '' : $url, 'disabled' => '' !== $reason, 'attrs' => $attrs, ) ); } /* translators: %s: how many rows are ticked. */ $picked = (string) ( $opts['picked'] ?? __( 'Exports the %s rows you ticked', 'ai-eshop-optimizer' ) ); return '' . $html . ( '' !== $scope ? '' . esc_html( $scope ) . '' : '' ) . ( '' !== $words ? '' . esc_html( $words ) . '' : '' ) . ( '' !== $reason ? '' . esc_html( $reason ) . '' : '' ) . ''; } /** * A total's cell value: a number through number_format_i18n() (the site's locale: FR13), a string as the product * formatted it, {html} as escaped HTML. * * @param mixed $value The total. * @param int $decimals The decimals of a float (2 by default). */ public static function total( $value, int $decimals = 2 ): string { if ( is_array( $value ) && isset( $value['html'] ) ) { return (string) $value['html']; } if ( is_int( $value ) ) { return esc_html( number_format_i18n( $value ) ); } if ( is_float( $value ) ) { return esc_html( number_format_i18n( $value, max( 0, $decimals ) ) ); } return esc_html( is_scalar( $value ) ? (string) $value : '' ); } }