$attrs name => value. */ public static function attrs( array $attrs ): string { $out = ''; foreach ( $attrs as $name => $value ) { if ( null === $value || false === $value ) { continue; } $name = (string) $name; if ( 1 !== preg_match( '/^[a-zA-Z][a-zA-Z0-9:_-]*$/', $name ) ) { continue; } if ( true === $value || '' === $value ) { $out .= ' ' . $name; continue; } $out .= ' ' . $name . '="' . ( in_array( $name, array( 'href', 'src', 'formaction' ), true ) ? esc_url( (string) $value ) : esc_attr( (string) $value ) ) . '"'; } return $out; } /** * A builder's own attributes and a caller's `attrs` (0.84.6, the Rooster One cleanup: a script's hook passed as a * class in a select's `attrs` was dropped, and the navigator's colours never saved): the caller's add to the * builder's, the builder's win where both set one, and the caller's `class` is appended to the builder's own, never * dropped or printed twice. * * @param array $own The builder's attributes. * @param mixed $more The caller's `attrs` (anything but an array is none). * @return array */ public static function merge_attrs( array $own, $more ): array { $more = is_array( $more ) ? $more : array(); $add = trim( (string) ( $more['class'] ?? '' ) ); unset( $more['class'] ); if ( '' !== $add ) { $own['class'] = trim( (string) ( $own['class'] ?? '' ) . ' ' . $add ); } return $own + $more; } /** * A caller's `attrs` for a builder that prints its own class first (0.84.6, as merge_attrs()): the classes to append * to it (escaped, with their space), and the rest. * * @param mixed $attrs The caller's `attrs`. * @return array{0: string, 1: array} */ private static function split_class( $attrs ): array { $attrs = is_array( $attrs ) ? $attrs : array(); $class = trim( (string) ( $attrs['class'] ?? '' ) ); unset( $attrs['class'] ); return array( '' !== $class ? ' ' . esc_attr( $class ) : '', $attrs ); } /** * A button, or a link that looks like one. * * @param string $label The label (raw). * @param array $opts variant (outline, primary, secondary, ghost, destructive, mono), size (sm, * lg), block (fills its row), icon (Lucide name), icon_only (the label * becomes the name and the tip), href (an ``), external (a new tab), type * (button, submit; 0.85.2 reset: a form's Discard), disabled, attrs (more attributes, data-* included), class; * 0.59.0: reason (raw: why it is disabled, UI-Final-Review FR7; the button is * disabled and the reason follows it as visible text, tied by aria-describedby, * kept in Compact; its id is the button's `id` attribute with `-reason`, else a * unique one); 0.77.1: done (its action is done for this record: `is-done`, the * success colours and the check in place of its icon; still a button, so the * action runs again; a script marks one done with the class alone). */ public static function button( string $label, array $opts = array() ): string { $reason = (string) ( $opts['reason'] ?? '' ); if ( '' !== $reason ) { $attrs = is_array( $opts['attrs'] ?? null ) ? $opts['attrs'] : array(); $rid = ( '' !== (string) ( $attrs['id'] ?? '' ) ? (string) $attrs['id'] : wp_unique_id( 'rooster-admin-button-' ) ) . '-reason'; $opts = array( 'disabled' => true, 'reason' => '', 'attrs' => array( 'aria-describedby' => trim( (string) ( $attrs['aria-describedby'] ?? '' ) . ' ' . $rid ) ) + $attrs, ) + $opts; return self::button( $label, $opts ) . '' . esc_html( $reason ) . ''; } $variant = (string) ( $opts['variant'] ?? 'outline' ); $class = 'rooster-admin-button' . ( in_array( $variant, self::BUTTON_VARIANTS, true ) && 'outline' !== $variant ? ' rooster-admin-button--' . $variant : '' ) . ( in_array( $opts['size'] ?? '', array( 'sm', 'lg' ), true ) ? ' rooster-admin-button--' . $opts['size'] : '' ) . ( ! empty( $opts['block'] ) ? ' rooster-admin-button--block' : '' ) . ( ! empty( $opts['icon_only'] ) ? ' rooster-admin-button--icon' : '' ) . ( ! empty( $opts['done'] ) ? ' is-done' : '' ) . ( '' !== (string) ( $opts['class'] ?? '' ) ? ' ' . (string) $opts['class'] : '' ); $icon ='' !== (string) ( $opts['icon'] ?? '' ) ? Icons::svg( (string) $opts['icon'], ! empty( $opts['icon_only'] ) ? 16 : 15 ) : ''; $text = ! empty( $opts['icon_only'] ) ? '' . esc_html( $label ) . '' : esc_html( $label ); $attrs = is_array( $opts['attrs'] ?? null ) ? $opts['attrs'] : array(); $attrs = self::merge_attrs( array( 'class' => $class ), $attrs ); // A link to a new tab says so (0.64.3, WCAG 3.2.5): the icon shows it, the words tell a screen reader. $external = ! empty( $opts['external'] ) && '' !== (string) ( $opts['href'] ?? '' ); $new_tab = __( '(opens in a new tab)', 'ai-eshop-optimizer' ); if ( ! empty( $opts['icon_only'] ) ) { $attrs += array( 'aria-label' => $external ? $label . ' ' . $new_tab : $label, 'data-ra-tip' => $label, ); } if ( '' !== (string) ( $opts['href'] ?? '' ) ) { $attrs += array( 'href' => (string) $opts['href'] ); if ( $external ) { $attrs += array( 'target' => '_blank', 'rel' => 'noopener noreferrer', ); } if ( ! empty( $opts['disabled'] ) ) { $attrs += array( 'aria-disabled' => 'true' ); } return '' . $icon . $text . ( $external ? Icons::ui( 'external', 13 ) . ( empty( $opts['icon_only'] ) ? ' ' . esc_html( $new_tab ) . '' : '' ) : '' ) . ''; } $attrs += array( 'type' => in_array( $opts['type'] ?? '', array( 'submit', 'reset' ), true ) ? (string) $opts['type'] : 'button' ); if ( ! empty( $opts['disabled'] ) ) { $attrs += array( 'disabled' => true ); } return '' . $icon . $text . ''; } /** * A badge (a tone on a word), or a chip (an outlined one, removable). * * @param string $label The word (raw). * @param string $tone A tone (TONES). * @param array $opts solid (filled), sm, lg (as tall as a small button), dot (a leading dot), chip (outlined, with a remove * button when `remove` is set to a label), icon (Lucide), href (the text * is a link; `href_label` its accessible name), clip (characters), attrs. * 0.44.2: html (escaped inline HTML in place of the text: links in a chip; * the label stays what `clip` measures), tip (the clipped chip's tip, by * default the label), class (more classes: a page's state), link_attrs, * remove_attrs (a script's hooks), after (escaped HTML after the text: an * icon link, `rooster-admin-chip__action`). */ public static function badge( string $label, string $tone = 'mono', array $opts = array() ): string { $clip = (int) ( $opts['clip'] ?? 0 ); // a chip in a tight spot: clipped past this many characters, the full text as its tip (hover and keyboard) $long = $clip > 0 && mb_strlen( $label ) > $clip; $class = ( ! empty( $opts['chip'] ) ? 'rooster-admin-chip' : 'rooster-admin-badge' ) . ' is-' . self::tone( $tone ) . ( ! empty( $opts['solid'] ) ? ' is-solid' : '' ) . ( ! empty( $opts['sm'] ) ? ' is-sm' : '' ) . ( ! empty( $opts['lg'] ) ? ' is-lg' : '' ) . ( $long ? ' is-clipped' : '' ) . ( '' !== (string) ( $opts['class'] ?? '' ) ? ' ' . (string) $opts['class'] : '' ); $attrs = self::merge_attrs( array( 'class' => $class ), $opts['attrs'] ?? null ); if ( $long ) { $attrs += array( 'data-ra-tip' => (string) ( $opts['tip'] ?? $label ), 'tabindex' => '0', ); } $html = '' . ( ! empty( $opts['dot'] ) ? '' : '' ) . ( '' !== (string) ( $opts['icon'] ?? '' ) ? Icons::svg( (string) $opts['icon'], 12 ) : '' ); if ( '' !== (string) ( $opts['href'] ?? '' ) ) { $link = array( 'class' => 'rooster-admin-chip__link' . ( $long ? ' rooster-admin-badge__text' : '' ), 'href' => (string) $opts['href'], ); if ( '' !== (string) ( $opts['href_label'] ?? '' ) ) { $link += array( 'aria-label' => (string) $opts['href_label'], 'data-ra-tip' => (string) $opts['href_label'], ); } $html .= '' . esc_html( $label ) . ''; } else { $text = '' !== (string) ( $opts['html'] ?? '' ) ? (string) $opts['html'] : esc_html( $label ); $html .= $long ? '' . $text . '' : $text; } $html .= (string) ( $opts['after'] ?? '' ); if ( ! empty( $opts['chip'] ) && '' !== (string) ( $opts['remove'] ?? '' ) ) { $html .= ' 'button', 'class' => 'rooster-admin-chip__remove', 'aria-label' => (string) $opts['remove'], ) + ( is_array( $opts['remove_attrs'] ?? null ) ? $opts['remove_attrs'] : array() ) ) . '>' . Icons::ui( 'close', 12 ) . ''; } return $html . ''; } /** * A notice: a page notice (between the cards) or an inline one (inside a card), in a tone, with an optional * title, a dismiss button and actions. * * @param string $html The text (escaped HTML: ``, `` allowed by the page's kses). * @param string $tone info, success, warning, destructive, or mono (a neutral note: nothing to act * on; 0.42.0). * @param array $opts title (raw), inline, dismissible, detail (escaped HTML: a block under the * text, such as the items an action refused, 0.53.0), actions (escaped HTML), id, * icon (Lucide; default by tone), attrs (a product's data hooks, 0.53.0). */ public static function notice( string $html, string $tone = 'info', array $opts = array() ): string { $tone = in_array( $tone, array( 'info', 'success', 'warning', 'destructive', 'mono' ), true ) ? $tone : 'info'; $icons = array( 'info' => 'info', 'success' => 'circle-check', 'warning' => 'triangle-alert', 'destructive' => 'circle-alert', 'mono' => 'message-square', ); $icon = (string) ( $opts['icon'] ?? $icons[ $tone ] ); $role = 'destructive' === $tone || 'warning' === $tone ? 'alert' : 'status'; $class = 'rooster-admin-notice is-' . $tone . ( ! empty( $opts['inline'] ) ? ' is-inline' : '' ); $split = self::split_class( $opts['attrs'] ?? null ); $out = '
' . ( '' !== $icon ? Icons::svg( $icon, 18, 'rooster-admin-notice__icon' ) : '' ) . '
' . ( '' !== (string) ( $opts['title'] ?? '' ) ? '

' . esc_html( (string) $opts['title'] ) . '

' : '' ) . '

' . $html . '

' . ( '' !== (string) ( $opts['detail'] ?? '' ) ? '
' . (string) $opts['detail'] . '
' : '' ) . ( '' !== (string) ( $opts['actions'] ?? '' ) ? '
' . (string) $opts['actions'] . '
' : '' ) . '
'; if ( ! empty( $opts['dismissible'] ) ) { $out .= ''; } return $out . '
'; } /** * A tab strip: the page's tabs with counts, the current one marked, an optional secondary row, the library's pin * on the current tab when its catalogue entry is named. * * @param array> $tabs {id, label, url, count (int|null), icon (Lucide), end (true: at * the strip's end), entry (catalogue id for the pin), attrs (on * the link: an id, a data-* hook; 0.44.0)}. * @param string $active The current tab's id. * @param array $opts sub (a secondary strip), label (the nav's name), class, attrs * (on the nav; 0.44.0). */ public static function tabs( array $tabs, string $active, array $opts = array() ): string { $split = self::split_class( $opts['attrs'] ?? null ); $html = '
'; } /** * A field row: the label, the control, the help, the error; stacked or side by side by the container. * * @param array $field id (the control's id), label (raw), control (escaped HTML), help (raw) or * help_html (escaped HTML; 0.44.0), error (raw), required, group (true: a * group of controls, labelled by a span), pin (escaped HTML), class, attrs * (on the row; 0.44.0), help_id (the help's id, by default the control's id with * `-help`; 0.55.0). Since 0.59.0 (UI-Final-Review FR6, FR7): the help, the error * and the reason are tied to the control (`aria-describedby` on the element whose * id is `id`; on the row itself for a group), never twice; reason (raw: why the * control is disabled, visible right after it, `-reason`; disable the control * in its own builder); parts (a compound row: {label, control, id, attrs (0.84.7)} * per part, Fields::compound(); the row is a group) with joiner (raw: '×', 'to'). Since * 0.83.2: wide (true: in a fields grid the row spans every column, `is-wide`: a * control whose choices read whole sentences; elsewhere a row is full width). */ public static function field_row( array $field ): string { $id = (string) ( $field['id'] ?? '' ); $label = (string) ( $field['label'] ?? '' ); $parts = is_array( $field['parts'] ?? null ) ? $field['parts'] : array(); $group = ! empty( $field['group'] ) || array() !== $parts; $error = (string) ( $field['error'] ?? '' ); $reason = (string) ( $field['reason'] ?? '' ); $attrs = is_array( $field['attrs'] ?? null ) ? $field['attrs'] : array(); $help = '' !== (string) ( $field['help_html'] ?? '' ) ? (string) $field['help_html'] : esc_html( (string) ( $field['help'] ?? '' ) ); $hid = '' !== $help ? (string) ( $field['help_id'] ?? ( '' !== $id ? $id . '-help' : '' ) ) : ''; $rid = '' !== $reason && '' !== $id ? $id . '-reason' : ''; // FR6, FR7: what describes the control, in reading order: its error, its help, why it is disabled. $tied = array_values( array_filter( array( '' !== $error && '' !== $id ? $id . '-error' : '', $hid, $rid ) ) ); $control = array() !== $parts ? Fields::compound( $parts, array( 'label' => $label, 'joiner' => (string) ( $field['joiner'] ?? '' ), ) ) : (string) ( $field['control'] ?? '' ); if ( array() !== $tied && $group ) { $attrs['aria-describedby'] = implode( ' ', array_unique( array_merge( preg_split( '/\s+/', trim( (string) ( $attrs['aria-describedby'] ?? '' ) ), -1, PREG_SPLIT_NO_EMPTY ), $tied ) ) ); } elseif ( array() !== $tied && '' !== $id ) { $control = self::describe( $control, $id, $tied ); } $html = '
' . '
' . ( $group || '' === $id ? '' : '' ) . '
' . '
' . $control . ( '' !== $reason ? '

' . esc_html( $reason ) . '

' : '' ) . '
' . '
' . (string) ( $field['pin'] ?? '' ) . '
'; if ( '' !== $error ) { $html .= '

' . Icons::svg( 'circle-alert', 14 ) . esc_html( $error ) . '

'; } if ( '' !== $help ) { $html .= '

' . $help . '

'; } return $html . '
'; } /** * A control's markup with ids added to its `aria-describedby` (0.59.0, FR6): the element whose id is `$id` (an input, * a select, a textarea, a button, or since 0.68.11 a rich text field: any element with role="textbox") keeps what * describes it already and gains the ids it lacks; markup without such an element is returned as it is. * * @param string $html Escaped HTML. * @param string $id The control's id. * @param array $ids The ids that describe it. */ public static function describe( string $html, string $id, array $ids ): string { $pattern = '/<(?:input|select|textarea|button|[a-z][a-z0-9]*(?=[^>]*\srole="textbox"))\b[^>]*\sid="' . preg_quote( esc_attr( $id ), '/' ) . '"[^>]*>/'; return (string) preg_replace_callback( $pattern, static function ( array $m ) use ( $ids ): string { $tag = $m[0]; if ( 1 === preg_match( '/\saria-describedby="([^"]*)"/', $tag, $d ) ) { $all = array_unique( array_merge( preg_split( '/\s+/', trim( $d[1] ), -1, PREG_SPLIT_NO_EMPTY ), $ids ) ); return str_replace( $d[0], ' aria-describedby="' . esc_attr( implode( ' ', $all ) ) . '"', $tag ); } return (string) preg_replace( '/\s*\/?>$/', ' aria-describedby="' . esc_attr( implode( ' ', $ids ) ) . '"$0', $tag, 1 ); }, $html, 1 ); } /** * One filter of a filter bar (`
`, 0.44.1): its label over its control. The * bar lays the filters on one line, bottom-aligned, with its actions (`rooster-admin-filters__actions`: Apply, * Clear) at the end. * * @param string $label The label (raw). * @param string $control The control (escaped HTML: Ui::select(), Ui::input()). * @param string $id The control's id (the label is for it). * @param array $opts tip (raw, 0.48.1: why the control is disabled, "Choose an organisation * first", the chrome's tip on the field: a disabled control takes no hover). */ public static function filter( string $label, string $control, string $id = '', array $opts = array() ): string { $tip = (string) ( $opts['tip'] ?? '' ); return '
' . ( '' !== $id ? '' : '' ) . $control . '
'; } /** * The attributes a control takes from its options. * * @param string $name The name. * @param array $opts id, placeholder, required, disabled, readonly, invalid, help (the help's id * is `-help`), attrs (a class among them is appended to the control's own, * merge_attrs(), 0.84.6). * @return array */ private static function control_attrs( string $name, array $opts ): array { $id = (string) ( $opts['id'] ?? $name ); $attrs = array( 'id' => $id, 'name' => $name, ); // A control without a name is a script's own (a search a script answers, never submitted): no name, and no id // unless one is given (0.54.2). if ( '' === $name ) { unset( $attrs['name'] ); if ( '' === $id ) { unset( $attrs['id'] ); } } foreach ( array( 'placeholder', 'autocomplete', 'min', 'max', 'step', 'maxlength', 'pattern', 'inputmode', 'rows', 'aria-label' ) as $key ) { // aria-label: a control without a visible label (a toolbar's search, the second half of a range) if ( isset( $opts[ $key ] ) && '' !== (string) $opts[ $key ] ) { $attrs[ $key ] = (string) $opts[ $key ]; } } foreach ( array( 'required', 'disabled', 'readonly' ) as $flag ) { if ( ! empty( $opts[ $flag ] ) ) { $attrs[ $flag ] = true; } } if ( ! empty( $opts['invalid'] ) ) { $attrs['aria-invalid'] = 'true'; $attrs['aria-describedby'] = $id . '-error'; } elseif ( ! empty( $opts['help'] ) ) { $attrs['aria-describedby'] = $id . '-help'; } return $attrs + ( is_array( $opts['attrs'] ?? null ) ? $opts['attrs'] : array() ); } /** * A text-like input. * * @param string $name The name. * @param string $value The value. * @param array $opts type (text, email, url, number, search, password, date, time, tel; 0.59.0 * datetime-local and month: as wide as their value), xs (6rem: a number of up to * four digits, a size in px, a percentage, a duration, a delimiter; 0.59.0), short * (10rem: a number, a code), medium (20rem: a name, a label; 0.43.1), code (a * monospace value; 0.43.0), icon (a leading Lucide icon), unit (raw: a unit after * the field, "minutes", "px", "€"; 0.54.0), class (more classes; 0.59.0), and * control_attrs(). */ public static function input( string $name, string $value = '', array $opts = array() ): string { $type = in_array( $opts['type'] ?? '', array( 'text', 'email', 'url', 'number', 'search', 'password', 'date', 'time', 'tel', 'datetime-local', 'month' ), true ) ? (string) $opts['type'] : 'text'; $attrs = self::merge_attrs( array( 'type' => $type, 'class' => 'rooster-admin-input' . self::width( 'input', $opts ) . ( ! empty( $opts['code'] ) ? ' is-code' : '' ) . ( '' !== (string) ( $opts['class'] ?? '' ) ? ' ' . (string) $opts['class'] : '' ), 'value' => $value, ), self::control_attrs( $name, $opts ) ); $input = ''; if ( '' !== (string) ( $opts['icon'] ?? '' ) ) { $input = '' . Icons::svg( (string) $opts['icon'], 15, 'rooster-admin-inputwrap__icon' ) . $input . ''; } if ( '' !== (string) ( $opts['unit'] ?? '' ) ) { $input = '' . $input . '' . esc_html( (string) $opts['unit'] ) . ''; } return $input; } /** * A control's width class (0.59.0, the semantic sizing scale): xs 6rem, short 10rem, medium 20rem; none for the * default (an input 40rem; a select as wide as its longest option, at least 10rem). * * @param string $kind input or select. * @param array $opts xs, short, medium (the first set wins). */ private static function width( string $kind, array $opts ): string { foreach ( array( 'xs', 'short', 'medium' ) as $width ) { if ( ! empty( $opts[ $width ] ) ) { return ' rooster-admin-' . $kind . '--' . $width; } } return ''; } /** * A native select. * * @param string $name The name. * @param array $options value => label (raw); a nested array is an optgroup (label => options). * @param string $value The chosen value. * @param array $opts xs (6rem: a unit, a delimiter), short (10rem), medium (20rem) (0.59.0: a * select is as wide as its longest option by default, at least 10rem; a width * lines it up with its card's other fields), counts (0.65.6: value => how many * rows have it, said after its label, count()), class (0.84.6: more classes), and * control_attrs(). */ public static function select( string $name, array $options, string $value = '', array $opts = array() ): string { $counts = is_array( $opts['counts'] ?? null ) ? $opts['counts'] : array(); unset( $opts['counts'] ); $attrs = self::merge_attrs( array( 'class' => 'rooster-admin-select' . self::width( 'select', $opts ) . ( '' !== (string) ( $opts['class'] ?? '' ) ? ' ' . (string) $opts['class'] : '' ) ), self::control_attrs( $name, $opts ) ); $html = ''; foreach ( $options as $key => $label ) { if ( is_array( $label ) ) { $html .= ''; foreach ( $label as $k => $l ) { $html .= ''; } $html .= ''; continue; } $html .= '