|null */ private static ?array $manifest = null; /** * Vendored package location, relative to the plugin root. */ public const PATH = 'assets/vendor/rahkar-ui/'; /** * Design-system components these screens depend on. * * Checked against the vendored manifest by `missingComponents()`. Add a name * here when a screen starts rendering that component's markup — that is what * makes a stale vendored build announce itself instead of silently producing * an unstyled page. * * @var list */ private const REQUIRED_COMPONENTS = [ 'Badge', 'Button', 'Card', 'CodeBlock', 'EmptyState', 'FormRow', 'Notice', 'NumberField', 'Pagination', 'Panel', 'Select', 'StatTile', 'Table', 'Tabs', 'TextField', 'Textarea', 'Toggle', 'Toolbar', 'VerticalTabs', ]; /** * Handle the shared loader registers the webfont under. */ private const FONT_HANDLE = 'rahkar-ui-fonts'; /** * Memoised sprite URL. * * Resolved once per request rather than per icon; a table of thirty rows with * an icon in each would otherwise ask the loader thirty times. * * @var string|null */ private static ?string $sprite = null; /** * Help text captured by rowOpen() and emitted by rowClose(). * * @var string */ private static string $pendingHelp = ''; /** * The markup this class and the pages around it are allowed to print. * * Every `Ui` helper already escapes what it interpolates, but a page that * prints one of them runs the result through `wp_kses()` with this list as * well, so the output is escaped late, where it is echoed. The list is the * set of tags the helpers build — form controls, tables, tabs, the sprite * icons — and no scripts, styles or event attributes. * * @return array> */ public static function allowedHtml(): array { static $allowed = null; if ( null !== $allowed ) { return $allowed; } $global = [ 'id' => true, 'class' => true, 'title' => true, 'dir' => true, 'lang' => true, 'hidden' => true, 'role' => true, 'tabindex' => true, 'for' => true, 'data-*' => true, 'aria-label' => true, 'aria-labelledby' => true, 'aria-describedby' => true, 'aria-hidden' => true, 'aria-live' => true, 'aria-expanded' => true, 'aria-controls' => true, 'aria-current' => true, 'aria-selected' => true, 'aria-checked' => true, 'aria-pressed' => true, 'aria-disabled' => true, 'aria-haspopup' => true, 'aria-modal' => true, 'aria-atomic' => true, 'aria-busy' => true, 'aria-invalid' => true, 'aria-required' => true, 'aria-valuenow' => true, 'aria-valuemin' => true, 'aria-valuemax' => true, 'aria-orientation' => true, 'aria-sort' => true, 'aria-owns' => true, 'aria-relevant' => true, 'aria-autocomplete' => true, 'aria-readonly' => true, 'aria-multiselectable' => true, 'aria-level' => true, 'aria-posinset' => true, 'aria-setsize' => true, 'aria-errormessage' => true, 'aria-description' => true, 'aria-roledescription' => true, ]; $control = $global + [ 'type' => true, 'name' => true, 'value' => true, 'placeholder' => true, 'min' => true, 'max' => true, 'step' => true, 'minlength' => true, 'maxlength' => true, 'size' => true, 'rows' => true, 'cols' => true, 'pattern' => true, 'autocomplete' => true, 'inputmode' => true, 'list' => true, 'readonly' => true, 'disabled' => true, 'required' => true, 'checked' => true, 'multiple' => true, 'selected' => true, 'form' => true, ]; $tags = [ 'div', 'span', 'p', 'strong', 'em', 'b', 'i', 'small', 'code', 'pre', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'nav', 'section', 'header', 'footer', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'label', 'details', 'summary', ]; $allowed = []; foreach ( $tags as $tag ) { $allowed[ $tag ] = $global + [ 'colspan' => true, 'rowspan' => true, 'scope' => true, ]; } $allowed['a'] = $global + [ 'href' => true, 'target' => true, 'rel' => true, 'download' => true, ]; $allowed['button'] = $control; $allowed['input'] = $control; $allowed['select'] = $control; $allowed['textarea'] = $control; $allowed['option'] = $control; $allowed['optgroup'] = $control + [ 'label' => true ]; $allowed['img'] = $global + [ 'src' => true, 'alt' => true, 'width' => true, 'height' => true, ]; // The sprite icons: . $allowed['form'] = $global + [ 'method' => true, 'action' => true, 'enctype' => true, 'novalidate' => true, 'autocomplete' => true, ]; $allowed['noscript'] = []; $allowed['svg'] = $global + [ 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'focusable' => true, 'xmlns' => true, ]; $allowed['use'] = $global + [ 'href' => true, 'xlink:href' => true, ]; $allowed['title'] = []; $allowed['path'] = $global + [ 'd' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, ]; $allowed['circle'] = $global + [ 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, ]; return $allowed; } /** * Put this plugin's copy of the design system into the competition. * * Registering loads nothing. The loader resolves a winner at enqueue time, * which is the only way several Rahkar plugins can each ship a copy without * the oldest one overwriting the newest. */ public static function register(): void { require_once RAHKAR_WP_DIR . self::PATH . 'php/class-rahkar-ui.php'; Rahkar_UI::register( self::version(), RAHKAR_WP_DIR . self::PATH, RAHKAR_WP_URL . self::PATH, 'rahkar-toolkit' ); } /** * Load the design system's styles, and only its styles. * * `Rahkar_UI::enqueue()` would also enqueue `build/index.js`, which pulls in * `wp-components`, `wp-element` and the React runtime. These screens render * on the server and run no React at all, so this goes through * `register_assets()` and enqueues the stylesheet by hand: identical styling, * without ~100KB of JavaScript that nothing on the page would call. * * When a screen here does grow a React interface, that screen calls * `Rahkar_UI::enqueue()` instead and gets the script as well. */ public static function enqueueStyles(): void { if ( ! Rahkar_UI::register_assets() ) { return; } wp_enqueue_style( Rahkar_UI::handle() ); // Registered only when the woff2 is actually vendored, so a stripped-down // copy of the package falls back to the system stack instead of 404ing. if ( wp_style_is( self::FONT_HANDLE, 'registered' ) ) { wp_enqueue_style( self::FONT_HANDLE ); } } /** * The vendored design-system version, read from the build that ships it. * * This used to be a constant that someone had to remember to raise after * every sync. The shared loader picks the highest version any Rahkar plugin * registers, so a number that was not raised is simply never the copy that * loads — and nothing anywhere says so. That exact failure has already * happened once, which is why the number now comes from the same file it * describes instead of from a second place that can disagree with it. * * `0.0.0` when there is no readable manifest: a copy that cannot state its * version should lose the comparison, not win it by accident. */ private static function version(): string { $version = self::manifest()['version'] ?? ''; return is_string( $version ) && '' !== $version ? $version : '0.0.0'; } /** * The vendored build's manifest, read at most once per request. * * `register()` runs on every admin request, so this is on a path that is * supposed to stay cheap. One small read of a file the plugin ships, held for * the rest of the request, and no database access at all. * * @return array */ private static function manifest(): array { if ( null !== self::$manifest ) { return self::$manifest; } $file = RAHKAR_WP_DIR . self::PATH . 'build/manifest.json'; if ( ! is_readable( $file ) ) { self::$manifest = []; return self::$manifest; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A file shipped inside the plugin, not a remote resource. $decoded = json_decode( (string) file_get_contents( $file ), true ); self::$manifest = is_array( $decoded ) ? $decoded : []; return self::$manifest; } /** * Load the submit guard, and nothing else. * * Its own entry in the design system's build, with an empty `dependencies` * array, so a server-rendered screen can have it without `build/index.js` and * the `wp-element`/`wp-components` runtime that one declares. Roughly 2KB * against roughly 1.15MB. * * Returns the handle so the caller can declare it as a dependency and be sure * of the order. An empty string means the vendored copy predates the split, in * which case the screen still works — it just submits every field. */ public static function enqueueFormGuard(): string { return Rahkar_UI::enqueue_form_guard() ? (string) Rahkar_UI::guard_handle() : ''; } /** * The design-system version that won resolution, or an empty string. * * The health screen reports it: a vendored copy missing its build output * would otherwise show up as an unstyled screen with no explanation. */ public static function loadedVersion(): string { return (string) ( Rahkar_UI::version() ?? '' ); } /** * Components the vendored build actually contains. * * Read from `build/manifest.json`, which the design system's build writes and * `bin/sync-ui.php` copies. An empty list means either a build predating the * manifest or a vendored copy that was never synced — both of which the health * screen reports the same way, because the remedy is the same. * * @return list */ public static function vendoredComponents(): array { $components = self::manifest()['components'] ?? null; if ( ! is_array( $components ) ) { return []; } return array_values( array_map( 'strval', $components ) ); } /** * Components this plugin renders markup for but the vendored build lacks. * * The failure this catches has no error attached to it. `VerticalTabs` was * built in the design system without a version bump, the sync had no reason to * run, and the vendored stylesheet carried none of its classes — so the markup * rendered, correctly, with no styling whatsoever. Nothing logged, nothing * threw. Naming the components here turns that into a row on this screen. * * @return list */ public static function missingComponents(): array { $vendored = self::vendoredComponents(); if ( [] === $vendored ) { return []; } return array_values( array_diff( self::REQUIRED_COMPONENTS, $vendored ) ); } /* * --------------------------------------------------------------------- * Page shell * --------------------------------------------------------------------- */ /** * Open a screen. * * `wrap` is WordPress's own class and stays: it supplies the margins that * place a screen correctly beside the admin menu, and core injects admin * notices into it. `rk-root` inside it is what scopes the font, the direction * and every token — the design system sets no global styles on purpose, so * nothing outside this element is touched. * * @param string $title Screen title. * @param string $subtitle Optional line under the title. * @param string $help Optional "?" link to the section's guide, from Help::link(). */ public static function pageOpen( string $title, string $subtitle = '', string $help = '' ): void { printf( '
%s

%s

', wp_kses( self::logo(), self::allowedHtml() ), esc_html( $title ) ); if ( '' !== $subtitle ) { printf( '

%s

', esc_html( $subtitle ) ); } echo '
'; if ( '' !== $help ) { echo wp_kses( $help, self::allowedHtml() ); } echo '
'; } /** * The "?" link to a section's guide, opening in a new tab. * * Drawn here rather than from the design system's sprite: the copy vendored * into this plugin predates the help glyph. The icon is inline, the class is * the plugin's own, and the label is there for the tooltip and for screen * readers. * * @param string $url The guide. * @param string $topic What the guide explains. */ public static function help( string $url, string $topic ): string { /* translators: %s: what the guide explains */ $label = sprintf( __( 'راهنمای %s', 'rahkar-toolkit' ), $topic ); return sprintf( '', esc_url( $url ), esc_attr( $label ), esc_attr( $label ) ); } /** * Close a screen. */ public static function pageClose(): void { echo '
'; } /* * --------------------------------------------------------------------- * Containers * --------------------------------------------------------------------- */ /** * Open a panel. * * @param string $title Panel heading; empty renders a bare panel. * @param string $description Optional line under the heading. * @param string $icon Optional sprite icon name. * @param string $aside Pre-escaped markup for the header's far corner. * @param string $id Optional element id, for in-page anchors. */ public static function panelOpen( string $title = '', string $description = '', string $icon = '', string $aside = '', string $id = '' ): void { printf( '
', '' === $id ? '' : ' id="' . esc_attr( $id ) . '"' ); if ( '' !== $title || '' !== $aside ) { echo '
'; if ( '' !== $title ) { printf( '

%s%s

', '' === $icon ? '' : wp_kses( self::icon( $icon ), self::allowedHtml() ), esc_html( $title ) ); } if ( '' !== $description ) { printf( '

%s

', esc_html( $description ) ); } echo '
'; if ( '' !== $aside ) { printf( '
%s
', wp_kses( $aside, self::allowedHtml() ) ); } echo '
'; } echo '
'; } /** * Close a panel, optionally with a footer. * * @param string $footer Pre-escaped markup, usually buttons. */ public static function panelClose( string $footer = '' ): void { echo '
'; if ( '' !== $footer ) { printf( '', wp_kses( $footer, self::allowedHtml() ) ); } echo '
'; } /** * Open a titled group of fields inside a panel. * * @param string $title Section heading; empty renders an untitled group. */ public static function sectionOpen( string $title = '' ): void { echo '
'; if ( '' !== $title ) { printf( '

%s

', esc_html( $title ) ); } } /** * Close a field group. */ public static function sectionClose(): void { echo '
'; } /** * Open the grid module cards sit in. */ public static function cardGridOpen(): void { echo '
'; } /** * Close the card grid. */ public static function cardGridClose(): void { echo '
'; } /** * Open a module card. * * The card has three states rather than two. "Blocked" is not "off": a module * that cannot run because WooCommerce is missing would look identical to one * the admin deliberately switched off, and that difference is the only thing * they need to know. Passing reasons puts the card in the third state and * lists them under the description. * * @param string $state One of `on`, `off`, `blocked`. * @param string $title Module name. * @param string $icon Sprite icon name. * @param string $badges Pre-escaped badge markup. * @param string $body Module description. * @param list $blockers Reasons the module cannot run. * @param string $control Pre-escaped markup for the header's control slot. * @param string $id Optional element id, so a link can open on this card (`#id`). */ public static function cardOpen( string $state, string $title, string $icon, string $badges = '', string $body = '', array $blockers = [], string $control = '', string $id = '' ): void { printf( '
%s

%s%s

', esc_attr( $state ), '' === $id ? '' : ' id="' . esc_attr( $id ) . '"', wp_kses( self::icon( 'blocked' === $state ? 'lock' : $icon ), self::allowedHtml() ), esc_html( $title ), wp_kses( $badges, self::allowedHtml() ) ); if ( '' !== $body ) { printf( '

%s

', esc_html( $body ) ); } if ( [] !== $blockers ) { echo '
    '; foreach ( $blockers as $reason ) { printf( '
  • %s%s
  • ', wp_kses( self::icon( 'alert', 'sm' ), self::allowedHtml() ), esc_html( $reason ) ); } echo '
'; } echo '
'; if ( '' !== $control ) { printf( '
%s
', wp_kses( $control, self::allowedHtml() ) ); } echo '
'; } /** * Close a module card, optionally with a footer. * * @param string $footer Pre-escaped markup, usually buttons. * @param bool $between Push the footer's children to opposite ends. */ public static function cardClose( string $footer = '', bool $between = false ): void { if ( '' !== $footer ) { printf( // `rahkar-card__footer` pins it to the bottom (admin.css), so a // row of cards with descriptions of different lengths keeps its // buttons on one line. '', $between ? ' rk-card__footer--between' : '', wp_kses( $footer, self::allowedHtml() ) ); } echo '
'; } /* * --------------------------------------------------------------------- * Data * --------------------------------------------------------------------- */ /** * Open a table. * * The wrapper is what lets a wide table scroll inside itself rather than * stretching the page, which matters on a logs screen with a context column. * * @param list $columns Column headings. * @param bool $compact Tighter rows, for long lists. */ public static function tableOpen( array $columns, bool $compact = false ): void { printf( '
', $compact ? ' rk-table--compact' : '' ); foreach ( $columns as $heading ) { printf( '', esc_html( $heading ) ); } echo ''; } /** * Close a table. */ public static function tableClose(): void { echo '
%s
'; } /** * A full-width row standing in for an empty table body. * * @param int $columns Number of columns to span. * @param string $message Text to show. */ public static function tableEmptyRow( int $columns, string $message ): string { return sprintf( '%s', $columns, esc_html( $message ) ); } /** * Open a toolbar. * * @param bool $attached Join it visually to the table directly below. */ public static function toolbarOpen( bool $attached = true ): void { printf( '
', $attached ? ' rk-toolbar--attached' : '' ); } /** * Close a toolbar. */ public static function toolbarClose(): void { echo '
'; } /** * Push everything after it to the far end of the toolbar. */ public static function toolbarSpacer(): string { return ''; } /** * A small label inside a toolbar. * * @param string $text Label text. */ public static function toolbarLabel( string $text ): string { return sprintf( '%s', esc_html( $text ) ); } /** * Open a row of stat tiles. */ public static function statsOpen(): void { echo '
'; } /** * Close a row of stat tiles. */ public static function statsClose(): void { echo '
'; } /** * A single figure with its label. * * @param string $value The number or short string being reported. * @param string $label What it measures. * @param string $hint Optional smaller line underneath. * @param string $status One of `success`, `warning`, `error`, `brand`, or empty. */ public static function stat( string $value, string $label, string $hint = '', string $status = '' ): string { return sprintf( '
%s%s%s
', '' === $status ? '' : ' rk-stat--' . esc_attr( $status ), esc_html( $value ), esc_html( $label ), '' === $hint ? '' : sprintf( '%s', esc_html( $hint ) ) ); } /** * Page-through links. * * @param int $current Current page, 1-based. * @param int $total Total pages. * @param callable $hrefFor Receives a page number, returns its URL. * @param string $summary Optional "showing x of y" line. */ public static function pagination( int $current, int $total, callable $hrefFor, string $summary = '' ): string { if ( $total < 2 ) { return '' === $summary ? '' : sprintf( '
%s
', esc_html( $summary ) ); } $items = ''; // A window around the current page, with gaps standing in for the rest. A // log table can run to hundreds of pages, and printing every number is // both unusable and slower to render than the rows themselves. foreach ( self::pageWindow( $current, $total ) as $page ) { if ( null === $page ) { $items .= '…'; continue; } $items .= sprintf( '%s', $page === $current ? ' is-current' : '', esc_url( $hrefFor( $page ) ), $page === $current ? ' aria-current="page"' : '', esc_html( number_format_i18n( $page ) ) ); } return sprintf( '
%s%s
', $items, '' === $summary ? '' : sprintf( '%s', esc_html( $summary ) ) ); } /** * A row of links acting as tabs. * * Links rather than buttons, because each view here is its own URL: a tab the * admin can bookmark, reload, or open in a second window is worth more than * one that only exists as client-side state. * * @param array $items Tabs, keyed by view id. * @param string $active The current view id. * @param string $ariaName Accessible name for the tab list. */ public static function tabs( array $items, string $active, string $ariaName = '' ): string { $rendered = ''; foreach ( $items as $key => $item ) { $current = (string) $key === $active; $rendered .= sprintf( '%s%s', esc_url( $item['href'] ), $current ? 'page' : 'false', esc_html( $item['label'] ), isset( $item['count'] ) ? sprintf( '%s', esc_html( number_format_i18n( $item['count'] ) ) ) : '' ); } return sprintf( '', '' === $ariaName ? '' : sprintf( ' aria-label="%s"', esc_attr( $ariaName ) ), $rendered ); } /** * Open the two-column shell a vertical tab set lives in. * * A plain element rather than the form, which is what the design system's demo * puts the class on. The difference matters here: the delivery-test panel * cannot sit inside the settings form, so the form has to be able to close * while the layout stays open around it. */ public static function vtabsLayoutOpen(): void { echo '
'; } /** * Close the vertical-tab shell. */ public static function vtabsLayoutClose(): void { echo '
'; } /** * The navigation column of a vertical tab set. * * Buttons rather than links, unlike `tabs()`. A horizontal tab here is a view * with its own URL; a vertical tab is a slice of one form, and giving it an * href would mean either losing unsaved edits on every switch or inventing a * URL for a section that has no page of its own. * * Two levels and no more. A third would mean three clicks to reach one field. * * @param array> $items Tabs, keyed by tab id; an `items` key makes an entry a group. * @param string $active Selected tab id. * @param string $ariaName Accessible name for the navigation. * @param bool $searchable Render the filter box. */ public static function vtabsNav( array $items, string $active, string $ariaName = '', bool $searchable = false ): string { $list = ''; foreach ( $items as $key => $item ) { $children = $item['items'] ?? []; if ( [] === $children ) { $list .= sprintf( '
  • %s
  • ', self::vtabsItem( (string) $key, $item, $active ) ); continue; } // A group opens when it holds the active tab. Any other default would // hide the panel the page is currently showing behind a closed group. $open = array_key_exists( $active, $children ); $rendered = ''; foreach ( $children as $childKey => $child ) { $rendered .= sprintf( '
  • %s
  • ', self::vtabsItem( (string) $childKey, $child, $active ) ); } $list .= sprintf( '
  • ' . '' . '
      %s
  • ', $open ? 'true' : 'false', $open ? 'true' : 'false', isset( $item['icon'] ) ? self::icon( (string) $item['icon'], 'sm' ) : '', esc_html( $item['label'] ), self::icon( 'chevron-down', 'sm', '', 'rk-vtabs__chevron' ), $rendered ); } $search = $searchable ? sprintf( '', self::icon( 'search', 'sm', '', 'rk-vtabs__search-icon' ), esc_attr__( 'جست‌وجوی تنظیم…', 'rahkar-toolkit' ), esc_attr__( 'جست‌وجو در تنظیمات', 'rahkar-toolkit' ) ) : ''; return sprintf( '', '' === $ariaName ? '' : sprintf( ' aria-label="%s"', esc_attr( $ariaName ) ), $search, $list ); } /** * One selectable tab, at either level. * * @param string $key Tab id. * @param array $item Tab definition: `label`, optional `icon`, `count`, `countStatus`. * @param string $active Selected tab id. */ private static function vtabsItem( string $key, array $item, string $active ): string { $count = ''; if ( isset( $item['count'] ) && $item['count'] > 0 ) { $count = sprintf( '%s', empty( $item['countStatus'] ) ? '' : ' rk-vtabs__count--' . esc_attr( (string) $item['countStatus'] ), esc_html( number_format_i18n( (int) $item['count'] ) ) ); } return sprintf( '', $key === $active ? ' aria-current="page"' : '', esc_attr( $key ), isset( $item['icon'] ) ? self::icon( (string) $item['icon'], 'sm' ) : '', esc_html( $item['label'] ), $count ); } /** * Open the panel belonging to one tab. * * Every panel is rendered and only the inactive ones are hidden, so switching * tabs keeps unsaved edits and one submit carries the whole form. That choice * is what makes `Settings::save()` merging load-bearing rather than academic, * and it is also what puts `max_input_vars` on the horizon — the submit guard * is the answer to the second, not rendering one panel at a time. * * @param string $key Tab id this panel answers to. * @param string $scope Value for `data-rk-scope`; empty omits the attribute. * @param bool $active Whether this is the panel to show. */ public static function vtabsPanelOpen( string $key, string $scope = '', bool $active = false ): void { printf( '
    ', esc_attr( $key ), '' === $scope ? '' : sprintf( ' data-rk-scope="%s"', esc_attr( $scope ) ), $active ? '' : ' hidden' ); } /** * Close a tab panel. */ public static function vtabsPanelClose(): void { echo '
    '; } /** * The one confirmation dialog a screen needs, however many things it guards. * * Rendered empty and filled from whichever form was submitted, rather than one * dialog per destructive control. The cities screen has a delete button per * row, and a dialog each would put an unbounded number of hidden dialogs in * the document to say three different sentences. * * Call once per screen, outside every form — a dialog nested in one form * cannot confirm a different one, and a form inside a form is dropped by the * browser. */ public static function confirmModal(): void { printf( '', esc_attr__( 'بستن', 'rahkar-toolkit' ), wp_kses( self::icon( 'close', 'sm' ), self::allowedHtml() ), wp_kses( self::icon( 'alert' ), self::allowedHtml() ), esc_html__( 'انصراف', 'rahkar-toolkit' ) ); } /** * Attributes that route a form through the confirmation dialog. * * Put on the `
    ` rather than the button, because the form is what the * dialog ultimately submits and what carries the nonce and the action. * * `consequence` is for the one case on a screen that cannot be undone. Giving * it to every dialog is how it stops being read. * * @param string $title Dialog heading. * @param string $message What is about to happen. * @param string $confirm Label for the button that goes ahead. * @param string $tone `danger` or `warning`. * @param string $consequence Irreversibility warning; empty omits it. */ public static function confirmAttrs( string $title, string $message, string $confirm, string $tone = 'warning', string $consequence = '' ): string { return sprintf( ' data-rk-confirm data-rk-confirm-title="%s" data-rk-confirm-message="%s" data-rk-confirm-button="%s" data-rk-confirm-tone="%s"%s', esc_attr( $title ), esc_attr( $message ), esc_attr( $confirm ), esc_attr( 'danger' === $tone ? 'danger' : 'warning' ), '' === $consequence ? '' : sprintf( ' data-rk-confirm-consequence="%s"', esc_attr( $consequence ) ) ); } /** * Load the dialog's behaviour. * * Enqueued by the screen that called `confirmModal()`. Without it the forms * submit exactly as they did before the dialog existed, which is the right * failure: a confirmation that cannot render must not block the action. */ public static function enqueueConfirm(): void { wp_enqueue_script( 'rahkar-wp-admin-confirm', RAHKAR_WP_URL . 'assets/dist/admin-confirm.js', [], RAHKAR_WP_VERSION, true ); } /** * Nothing-here placeholder. * * @param string $title What is missing. * @param string $description Why, or what to do about it. * @param string $icon Sprite icon name. * @param string $actions Pre-escaped markup, usually a button. */ public static function emptyState( string $title, string $description = '', string $icon = 'package', string $actions = '' ): string { return sprintf( '
    %s

    %s

    %s%s
    ', self::icon( $icon, 'lg' ), esc_html( $title ), '' === $description ? '' : sprintf( '

    %s

    ', esc_html( $description ) ), '' === $actions ? '' : sprintf( '
    %s
    ', $actions ) ); } /** * Preformatted value — a cron line, an API response, a stack trace. * * @param string $value Raw text. * @param string $label Optional heading above it. */ public static function codeBlock( string $value, string $label = '' ): string { return sprintf( '
    %s
    %s
    ', '' === $label ? '' : sprintf( '%s', esc_html( $label ) ), esc_html( $value ) ); } /* * --------------------------------------------------------------------- * Feedback * --------------------------------------------------------------------- */ /** * An inline message. * * @param string $status One of `success`, `error`, `warning`, `info`. * @param string $text Message body. * @param string $title Optional bold first line. */ public static function notice( string $status, string $text, string $title = '' ): string { $glyphs = [ 'success' => 'check', 'error' => 'alert', 'warning' => 'alert', 'info' => 'info', ]; return sprintf( '
    %s
    %s
    %s
    ', esc_attr( $status ), self::icon( $glyphs[ $status ] ?? 'info', 'sm' ), '' === $title ? '' : sprintf( '

    %s

    ', esc_html( $title ) ), esc_html( $text ) ); } /** * A small status pill. * * @param string $text Label. * @param string $variant One of `success`, `error`, `warning`, `info`, `brand`, `critical`, `quiet`, or empty. * @param bool $mono Render latin or numeric text in the mono face. */ public static function badge( string $text, string $variant = '', bool $mono = false ): string { return sprintf( '%s', '' === $variant ? '' : ' rk-badge--' . esc_attr( $variant ), $mono ? ' rk-badge--mono' : '', esc_html( $text ) ); } /** * An icon from the shared sprite. * * Drawn with `` against one external SVG, so a table with an icon in * every row downloads the sprite once and references it thereafter. * * An icon with no label is decorative and is hidden from screen readers. * Passing a label makes it an image with an accessible name, for the rare case * where the icon is the only thing carrying the meaning. * * @param string $name Sprite name, without the `rk-` prefix. * @param string $size One of `sm`, `md`, `lg`. * @param string $label Accessible name; empty means decorative. * @param string $extra Extra classes. Some components position their own icon * — the vertical tab chevron rotates on `data-open`, the * search glyph is absolutely placed — and that styling * hangs off a class the sprite call has to carry. */ public static function icon( string $name, string $size = 'md', string $label = '', string $extra = '' ): string { if ( null === self::$sprite ) { self::$sprite = (string) Rahkar_UI::sprite_url(); } if ( '' === self::$sprite ) { return ''; } return sprintf( '%s', 'md' === $size ? '' : ' rk-icon--' . esc_attr( $size ), '' === $extra ? '' : ' ' . esc_attr( $extra ), '' === $label ? 'role="presentation" aria-hidden="true"' : sprintf( 'role="img" aria-label="%s"', esc_attr( $label ) ), esc_url( self::$sprite . '#rk-' . $name ), '' === $label ? '' : sprintf( '%s', esc_html( $label ) ) ); } /** * The Rahkarnet mark, for a screen header. */ public static function logo(): string { $url = (string) Rahkar_UI::logo_url(); return '' === $url ? '' : sprintf( '', esc_url( $url ) ); } /* * --------------------------------------------------------------------- * Controls * --------------------------------------------------------------------- */ /** * A button, or a link that looks like one. * * @param string $label Button text. * @param string $variant One of `primary`, `secondary`, `destructive`. * @param array $attrs Extra HTML attributes; an empty value renders a bare attribute. * @param string $href Render an anchor instead of a button. * @param string $icon Optional sprite icon name. */ public static function button( string $label, string $variant = 'secondary', array $attrs = [], string $href = '', string $icon = '' ): string { $classes = 'rk-button rk-button--' . $variant; if ( isset( $attrs['class'] ) ) { $classes .= ' ' . $attrs['class']; unset( $attrs['class'] ); } $rendered = ''; foreach ( $attrs as $key => $value ) { $rendered .= '' === $value ? sprintf( ' %s', esc_attr( $key ) ) : sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $value ) ); } $inner = ( '' === $icon ? '' : self::icon( $icon, 'sm' ) ) . esc_html( $label ); if ( '' !== $href ) { return sprintf( '%s', esc_attr( $classes ), esc_url( $href ), $rendered, $inner ); } return sprintf( '', esc_attr( $classes ), $rendered, $inner ); } /** * A switch. * * Rendered as a real checkbox with the track and thumb as siblings, which is * the structure the design system's stylesheet targets for the non-React case. * Keeping it a checkbox rather than a styled button is what makes it submit in * a plain form post, reachable by keyboard and announced correctly. * * @param string $name Input name. * @param bool $checked Current state. * @param array $attrs Extra HTML attributes. * @param string $label Visible label; empty renders the switch alone. * @param string $help Optional line under the label. * @param bool $disabled Render it inert. */ public static function toggle( string $name, bool $checked, array $attrs = [], string $label = '', string $help = '', bool $disabled = false ): string { $id = 'rk-t-' . sanitize_html_class( str_replace( [ '[', ']' ], '-', $name ) ); $rendered = ''; foreach ( $attrs as $key => $value ) { $rendered .= sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $value ) ); } $text = ''; if ( '' !== $label || '' !== $help ) { $text = sprintf( '
    %s%s
    ', '' === $label ? '' : sprintf( '', esc_attr( $id ), esc_html( $label ) ), '' === $help ? '' : sprintf( '%s', esc_html( $help ) ) ); } return sprintf( '
    %s
    ', $disabled ? ' is-disabled' : '', esc_attr( $id ), esc_attr( $name ), checked( $checked, true, false ), disabled( $disabled, true, false ), $rendered, $text ); } /** * Open a labelled form row. * * The label belongs to the row, not to the control inside it: giving a label * to both would have a screen reader announce two names for one field. * * @param string $label Field label. * @param string $for Id of the control inside. * @param string $help Optional guidance, printed after the control. * @param string $classes Extra classes for the row. */ public static function rowOpen( string $label, string $for = '', string $help = '', string $classes = '' ): void { printf( '
    ', '' === $classes ? '' : ' ' . esc_attr( $classes ) ); if ( '' !== $label ) { printf( '', '' === $for ? '' : ' for="' . esc_attr( $for ) . '"', esc_html( $label ) ); } echo '
    '; // Held rather than printed: help reads after the control it explains, and // that is the order the stylesheet expects too. self::$pendingHelp = $help; } /** * Close a form row. */ public static function rowClose(): void { if ( '' !== self::$pendingHelp ) { printf( '

    %s

    ', esc_html( self::$pendingHelp ) ); self::$pendingHelp = ''; } echo '
    '; } /** * A text-like input. * * The design system styles `.rk-field input` as well as `.rk-field__input`, so * the wrapper is what does the work and the input needs no class of its own — * but it carries one anyway, because a future stylesheet tightening that * bare-element selector should not silently unstyle every field here. * * @param string $type HTML input type. * @param string $name Input name; empty omits the attribute. * @param string $value Current value. * @param array $attrs Extra HTML attributes. * @param bool $mono Latin/numeric input — left-to-right, mono face. */ public static function input( string $type, string $name, string $value, array $attrs = [], bool $mono = false ): string { $rendered = ''; // A `class` in the attributes joins the control's own instead of being // printed as a second class attribute, which a browser ignores. It is how // a field asks for behaviour attached elsewhere — the Jalali picker binds // to a class. $extra = isset( $attrs['class'] ) ? ' ' . $attrs['class'] : ''; unset( $attrs['class'] ); foreach ( $attrs as $key => $attribute ) { $rendered .= '' === $attribute ? sprintf( ' %s', esc_attr( $key ) ) : sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $attribute ) ); } return sprintf( '
    ', $mono ? ' rk-field--mono' : '', esc_attr( $extra ), esc_attr( $type ), '' === $name ? '' : sprintf( ' name="%s"', esc_attr( $name ) ), esc_attr( $value ), $rendered ); } /** * A multi-line input. * * Proportional and right-to-left by default: most multi-line text here is a * Persian message, which the monospace treatment spaces out letter by letter * and turns left-to-right. Lists of numbers or addresses ask for `$mono`. * * The `rahkar-textarea` class lets the height follow `$rows`: the design * system gives every field control a fixed single-line height, textareas * included. * * @param string $name Input name; empty omits the attribute. * @param string $value Current value. * @param int $rows Visible rows. * @param array $attrs Extra HTML attributes. * @param bool $mono Monospace, left-to-right — for codes and lists. */ public static function textarea( string $name, string $value, int $rows = 5, array $attrs = [], bool $mono = false ): string { $rendered = ''; foreach ( $attrs as $key => $attribute ) { $rendered .= '' === $attribute ? sprintf( ' %s', esc_attr( $key ) ) : sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $attribute ) ); } return sprintf( '
    ', $mono ? ' rk-field--mono' : '', $rows, '' === $name ? '' : sprintf( ' name="%s"', esc_attr( $name ) ), $rendered, esc_textarea( $value ) ); } /** * A dropdown. * * The chevron is drawn rather than left to the browser, because the native * arrow sits on the wrong side in a right-to-left control on some platforms * and cannot be moved. * * @param string $name Input name; empty omits the attribute. * @param array $options Value to label. * @param string $selected Currently selected value. * @param array $attrs Extra HTML attributes. * @param bool $auto Size to content instead of filling the row. */ public static function select( string $name, array $options, string $selected, array $attrs = [], bool $auto = false ): string { $rendered = ''; foreach ( $attrs as $key => $attribute ) { $rendered .= '' === $attribute ? sprintf( ' %s', esc_attr( $key ) ) : sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $attribute ) ); } $choices = ''; foreach ( $options as $value => $label ) { $choices .= sprintf( '', esc_attr( (string) $value ), selected( (string) $selected, (string) $value, false ), esc_html( (string) $label ) ); } return sprintf( '
    %s
    ', $auto ? ' rk-select--auto' : '', '' === $name ? '' : sprintf( ' name="%s"', esc_attr( $name ) ), $rendered, $choices, // `rk-select__arrow` is what takes the glyph out of flow and places it // inside the control. Without it the SVG stays static and renders as a // second, stray chevron below the select. self::icon( 'chevron-down', 'sm', '', 'rk-select__arrow' ) ); } /** * Open a form. * * @param string $method HTTP method. * @param string $action Form target; empty posts to the current URL. * @param string $class Extra classes. */ public static function formOpen( string $method = 'post', string $action = '', string $class = '' ): void { printf( '', '' === $class ? '' : ' ' . esc_attr( $class ), esc_attr( $method ), '' === $action ? '' : ' action="' . esc_url( $action ) . '"' ); } /** * Close a form. */ public static function formClose(): void { echo ''; } /** * Page numbers to show, with `null` standing in for an elided run. * * @param int $current Current page. * @param int $total Total pages. * * @return list */ private static function pageWindow( int $current, int $total ): array { $pages = []; foreach ( range( 1, $total ) as $page ) { $near = abs( $page - $current ) <= 1; $edge = 1 === $page || $page === $total; if ( $near || $edge ) { $pages[] = $page; continue; } if ( [] !== $pages && null !== end( $pages ) ) { $pages[] = null; } } return $pages; } }