diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 6ba92816841..101bbfc6075 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -17,10 +17,10 @@ - Improved the accessibility of the Craft Support widget. ([#16293]](https://github.com/craftcms/cms/pull/16293)) ### Administration -- Added the “Show the ‘URL Suffix’ field” setting to Link fields. ([#15813](https://github.com/craftcms/cms/discussions/15813)) - Added the “Affiliated Site” native user field. ([#16174](https://github.com/craftcms/cms/pull/16174)) - Added support for setting site-specific email setting overrides. ([#16187](https://github.com/craftcms/cms/pull/16187)) - Added the “View users” user permission. ([#16206](https://github.com/craftcms/cms/pull/16206)) +- Added the “Advanced Fields” setting to Link fields, with “Target”, “URL Suffix”, “Title Text”, “ARIA Label”, “Class Name”, “ID”, and “Relation (rel)” options. ([#15813](https://github.com/craftcms/cms/discussions/15813)) - Added the “GraphQL Mode” Link field setting. ([#16237](https://github.com/craftcms/cms/pull/16237)) - Added the “Field” entry condition rule, which replaces “Matrix field”, includes a “has a value” operator. ([#16270](https://github.com/craftcms/cms/discussions/16270)) - Section condition rules now have a “has a value” operator. ([#16270](https://github.com/craftcms/cms/discussions/16270)) @@ -47,6 +47,11 @@ - Added `craft\elements\User::$affiliatedSiteId`. - Added `craft\elements\User::getAffiliatedSite()`. - Added `craft\elements\conditions\entries\FieldConditionRule`. +- Added `craft\fields\data\LinkData::$ariaLabel`. +- Added `craft\fields\data\LinkData::$class`. +- Added `craft\fields\data\LinkData::$id`. +- Added `craft\fields\data\LinkData::$rel`. +- Added `craft\fields\data\LinkData::$title`. - Added `craft\fields\data\LinkData::$urlSuffix`. - Added `craft\fields\data\LinkData::getUrl()`. - Added `craft\gql\types\LinkData`. @@ -58,7 +63,8 @@ - `craft\elements\conditions\entries\MatrixFieldConditionRule` is now an alias of `FieldConditionRule`. - `craft\helpers\Cp::elementIndexHtml()` now supports passing `defaultSort` in the `$config` array, when `sources` is `null`. ([#16236](https://github.com/craftcms/cms/discussions/16236)) - `craft\models\Site` now implements `craft\base\Chippable`. -- `craft\services\Revisions::createRevision()` no longer creates the revision if an `EVENT_BEFORE_CREATE_REVISION` event handler sets `$event->handled` to `true` and at least one revision already exists for the element. ([#16260](https://github.com/craftcms/cms/discussions/16260)) +- `craft\services\Revisions::createRevision()` no longer creates the revision if an `EVENT_BEFORE_CREATE_REVISION` event handler sets `$event->handled` to `true` and at least one revision already exists for the element. ([#16260](https://github.com/craftcms/cms/discussions/16260)) +- Deprecated `craft\fields\Link::$showTargetField`. - Sortable checkbox selects now always display the selected options first on initial render. ### System diff --git a/src/fields/Link.php b/src/fields/Link.php index 9459e300d2f..8d50c575d9e 100644 --- a/src/fields/Link.php +++ b/src/fields/Link.php @@ -35,6 +35,7 @@ use craft\helpers\Cp; use craft\helpers\Html; use craft\helpers\StringHelper; +use craft\helpers\Template; use craft\validators\ArrayValidator; use craft\validators\StringValidator; use GraphQL\Type\Definition\InputObjectType; @@ -103,6 +104,11 @@ public static function dbType(): array 'label' => Schema::TYPE_STRING, 'urlSuffix' => Schema::TYPE_STRING, 'target' => Schema::TYPE_STRING, + 'title' => Schema::TYPE_STRING, + 'class' => Schema::TYPE_STRING, + 'id' => Schema::TYPE_STRING, + 'rel' => Schema::TYPE_STRING, + 'ariaLabel' => Schema::TYPE_STRING, ]; } @@ -149,16 +155,11 @@ private static function types(): array public bool $showLabelField = false; /** - * @var bool Whether the “URL Suffix” field should be shown. + * @var string[] Attribute fields to show. + * @phpstan-var array<'urlSuffix'|'target'|'title'|'class'|'id'|'rel'|'ariaLabel'> * @since 5.6.0 */ - public bool $showUrlSuffixField = false; - - /** - * @var bool Whether the “Open in a new tab” field should be shown. - * @since 5.5.0 - */ - public bool $showTargetField = false; + public array $advancedFields = []; /** * @var array @@ -208,6 +209,18 @@ public function __construct($config = []) unset($config['placeholder']); } + $config['advancedFields'] ??= []; + + if (isset($config['showTargetField'])) { + unset($config['showTargetField']); + $config['advancedFields'][] = 'target'; + } + + if (isset($config['showUrlSuffixField'])) { + unset($config['showUrlSuffixField']); + $config['advancedFields'][] = 'urlSuffix'; + } + if (isset($config['graphqlMode'])) { $config['fullGraphqlData'] = ArrayHelper::remove($config, 'graphqlMode') === 'full'; } @@ -242,6 +255,25 @@ protected function defineRules(): array return $rules; } + /** + * @deprecated in 5.6.0 + * @return bool + */ + public function getShowTargetField(): bool + { + return in_array('target', $this->advancedFields); + } + + /** + * @deprecated in 5.6.0 + */ + public function setShowTargetField(bool $showTargetField): void + { + if (!$this->getShowTargetField()) { + $this->advancedFields[] = 'target'; + } + } + /** * Returns the link types available to the field. * @@ -380,17 +412,21 @@ public function getSettingsHtml(): ?string 'name' => 'showLabelField', 'on' => $this->showLabelField, ]) . - Cp::lightswitchFieldHtml([ - 'label' => Craft::t('app', 'Show the “URL Suffix” field'), - 'id' => 'show-url-suffix-field', - 'name' => 'showUrlSuffixField', - 'on' => $this->showUrlSuffixField, - ]) . - Cp::lightswitchFieldHtml([ - 'label' => Craft::t('app', 'Show the “Open in a new tab” field'), - 'id' => 'show-target-field', - 'name' => 'showTargetField', - 'on' => $this->showTargetField, + Cp::checkboxSelectFieldHtml([ + 'label' => Craft::t('app', 'Advanced Fields'), + 'id' => 'attribute-fields', + 'name' => 'advancedFields', + 'options' => [ + ['label' => Craft::t('app', 'URL Suffix'), 'value' => 'urlSuffix'], + ['label' => Craft::t('app', 'Target'), 'value' => 'target'], + ['label' => Craft::t('app', 'Title Text'), 'value' => 'title'], + ['label' => Craft::t('app', 'Class Name'), 'value' => 'class'], + ['label' => Craft::t('app', 'ID'), 'value' => 'id'], + ['label' => Template::raw(Craft::t('app', 'Relation ({ex})', ['ex' => 'rel'])), 'value' => 'rel'], + ['label' => Craft::t('app', 'ARIA Label'), 'value' => 'ariaLabel'], + ], + 'values' => $this->advancedFields, + 'sortable' => true, ]) . Html::tag('hr') . Html::button(Craft::t('app', 'Advanced'), options: [ @@ -457,9 +493,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed $type = $value->getType(); $value = [ 'type' => $type, - $type => [ - 'value' => sprintf('{%s:%s@%s:url}', $linkedElement::refHandle(), $linkedElement->id, $element->siteId), - ], + 'value' => sprintf('{%s:%s@%s:url}', $linkedElement::refHandle(), $linkedElement->id, $element->siteId), ]; } } @@ -474,10 +508,20 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed if (is_array($value)) { $typeId = $value['type'] ?? UrlType::id(); $config = array_filter([ - 'label' => $this->showLabelField ? ($value['label'] ?? null) : null, - 'urlSuffix' => $this->showUrlSuffixField ? ($value['urlSuffix'] ?? null) : null, - 'target' => $this->showTargetField ? ($value['target'] ?? null) : null, + 'label' => (!empty($value['label']) && $this->showLabelField) ? $value['label'] : null, + 'urlSuffix' => (!empty($value['urlSuffix']) && in_array('urlSuffix', $this->advancedFields)) ? $value['urlSuffix'] : null, + 'target' => (!empty($value['target']) && in_array('target', $this->advancedFields)) ? $value['target'] : null, + 'title' => (!empty($value['title']) && in_array('title', $this->advancedFields)) ? $value['title'] : null, + 'class' => (!empty($value['class']) && in_array('class', $this->advancedFields)) + ? (implode(' ', array_map(fn(string $class) => Html::id($class), explode(' ', $value['class'])))) + : null, + 'id' => (!empty($value['id']) && in_array('id', $this->advancedFields)) ? Html::id($value['id']) : null, + 'rel' => (!empty($value['rel']) && in_array('rel', $this->advancedFields)) + ? (implode(' ', array_map(fn(string $rel) => Html::id($rel), explode(' ', $value['rel'])))) + : null, + 'ariaLabel' => (!empty($value['ariaLabel']) && in_array('ariaLabel', $this->advancedFields)) ? $value['ariaLabel'] : null, ]); + $value = $value['value'] ?? $value[$typeId]['value'] ?? ''; if (is_string($value)) { @@ -629,7 +673,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inl Html::endTag('div'); } - $pane = $this->showLabelField || $this->showUrlSuffixField || $this->showTargetField; + $pane = $this->showLabelField || !empty($this->advancedFields); $html = Html::beginTag('div', [ 'id' => $id, @@ -658,29 +702,78 @@ protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inl ]); } - if ($this->showUrlSuffixField) { - $html .= Cp::textFieldHtml([ - 'fieldClass' => ['my-m', 'info-icon-instructions'], - 'label' => Craft::t('app', 'URL Suffix'), - 'instructions' => Craft::t('app', 'Query params (e.g. {ex1}) or a URI fragment (e.g. {ex2}) that should be appended to the URL.', [ - 'ex1' => '`?p1=foo&p2=bar`', - 'ex2' => '`#anchor`', - ]), - 'id' => "$id-url-suffix", - 'name' => "$this->handle[urlSuffix]", - 'value' => $value?->urlSuffix, - ]); - } + if (!empty($this->advancedFields)) { + $html .= + Html::button(Craft::t('app', 'Advanced'), options: [ + 'class' => ['fieldtoggle', 'mb-0'], + 'data' => ['target' => "$id-advanced"], + ]) . + Html::beginTag('div', [ + 'id' => "$id-advanced", + 'class' => ['hidden', 'meta', 'pane', 'hairline'], + ]); - if ($this->showTargetField) { - $html .= Cp::lightswitchFieldHtml([ - 'fieldClass' => 'my-m', - 'label' => Craft::t('app', 'Open in a new tab'), - 'id' => "$id-target", - 'name' => "$this->handle[target]", - 'on' => $value?->target, - 'value' => '_blank', - ]); + foreach ($this->advancedFields as $field) { + $html .= match ($field) { + 'urlSuffix' => Cp::textFieldHtml([ + 'fieldClass' => 'info-icon-instructions', + 'label' => Craft::t('app', 'URL Suffix'), + 'instructions' => Craft::t('app', 'Query params (e.g. {ex1}) or a URI fragment (e.g. {ex2}) that should be appended to the URL.', [ + 'ex1' => '`?p1=foo&p2=bar`', + 'ex2' => '`#anchor`', + ]), + 'id' => "$id-url-suffix", + 'name' => "$this->handle[urlSuffix]", + 'value' => $value?->urlSuffix, + ]), + 'target' => Cp::lightswitchFieldHtml([ + 'label' => Craft::t('app', 'Open in a new tab'), + 'id' => "$id-target", + 'name' => "$this->handle[target]", + 'on' => $value?->target, + 'value' => '_blank', + ]), + 'title' => Cp::textFieldHtml([ + 'label' => Craft::t('app', 'Title Text'), + 'id' => "$id-title", + 'name' => "$this->handle[title]", + 'value' => $value?->title, + ]), + 'class' => Cp::textFieldHtml([ + 'fieldClass' => 'info-icon-instructions', + 'class' => 'code', + 'label' => Craft::t('app', 'Class Name'), + 'instructions' => Craft::t('app', 'Separate multiple values with spaces.'), + 'id' => "$id-class", + 'name' => "$this->handle[class]", + 'value' => $value?->class, + ]), + 'id' => Cp::textFieldHtml([ + 'class' => 'code', + 'label' => Craft::t('app', 'ID'), + 'id' => "$id-id", + 'name' => "$this->handle[id]", + 'value' => $value?->id, + ]), + 'rel' => Cp::textfieldHtml([ + 'fieldClass' => 'info-icon-instructions', + 'class' => 'code', + 'label' => Craft::t('app', 'Relation ({ex})', ['ex' => 'rel']), + 'instructions' => Craft::t('app', 'Separate multiple values with spaces.'), + 'id' => "$id-rel", + 'name' => "$this->handle[rel]", + 'value' => $value?->rel, + ]), + 'ariaLabel' => Cp::textFieldHtml([ + 'label' => Craft::t('app', 'ARIA Label'), + 'id' => "$id-aria-label", + 'name' => "$this->handle[ariaLabel]", + 'value' => $value?->ariaLabel, + ]), + }; + } + + $html .= Html::endTag('div'); } $html .= Html::endTag('div'); diff --git a/src/fields/data/LinkData.php b/src/fields/data/LinkData.php index 1ea2ae25985..2ed7d5dca75 100644 --- a/src/fields/data/LinkData.php +++ b/src/fields/data/LinkData.php @@ -42,6 +42,36 @@ class LinkData extends BaseObject implements Serializable */ public ?string $target = null; + /** + * @var string|null The link’s `title` attribute. + * @since 5.6.0 + */ + public ?string $title = null; + + /** + * @var string|null The link’s `class` attribute. + * @since 5.6.0 + */ + public ?string $class = null; + + /** + * @var string|null The link’s `id` attribute. + * @since 5.6.0 + */ + public ?string $id = null; + + /** + * @var string|null The link’s `rel` attribute. + * @since 5.6.0 + */ + public ?string $rel = null; + + /** + * @var string|null The link’s `aria-label` attribute. + * @since 5.6.0 + */ + public ?string $ariaLabel = null; + private string $renderedValue; private ?string $label = null; @@ -129,6 +159,11 @@ public function getLink(): Markup $label = $this->getLabel(); $html = Html::a(Html::encode($label !== '' ? $label : $url), $url, [ 'target' => $this->target, + 'title' => $this->title, + 'class' => $this->class, + 'id' => $this->id, + 'rel' => $this->rel, + 'ariaLabel' => $this->ariaLabel, ]); } @@ -150,12 +185,17 @@ public function getElement(): ?ElementInterface public function serialize(): mixed { - return [ + return array_filter([ 'value' => $this->value, 'type' => $this->getType(), 'label' => $this->label, 'urlSuffix' => $this->urlSuffix, 'target' => $this->target, - ]; + 'title' => $this->title, + 'class' => $this->class, + 'id' => $this->id, + 'rel' => $this->rel, + 'ariaLabel' => $this->ariaLabel, + ]); } } diff --git a/src/gql/types/LinkData.php b/src/gql/types/LinkData.php index 029f162e589..2d4ff7460dc 100644 --- a/src/gql/types/LinkData.php +++ b/src/gql/types/LinkData.php @@ -31,6 +31,7 @@ protected function resolve(mixed $source, array $arguments, mixed $context, Reso 'value' => $source->getValue(), 'label' => $source->getLabel(true), 'url' => $source->getUrl(), + 'link' => $source->getLink(), 'elementType' => $source->getElement() ? $source->getElement()::class : null, 'elementId' => $source->getElement()?->id, 'elementSiteId' => $source->getElement()?->siteId, diff --git a/src/gql/types/generators/LinkDataType.php b/src/gql/types/generators/LinkDataType.php index 0da7fb574f3..030f5337030 100644 --- a/src/gql/types/generators/LinkDataType.php +++ b/src/gql/types/generators/LinkDataType.php @@ -53,6 +53,13 @@ public static function generateType(mixed $context): ObjectType 'label' => Type::string(), 'urlSuffix' => Type::string(), 'url' => Type::string(), + 'link' => Type::string(), + 'target' => Type::string(), + 'title' => Type::string(), + 'class' => Type::string(), + 'id' => Type::string(), + 'rel' => Type::string(), + 'ariaLabel' => Type::string(), 'elementType' => Type::string(), 'elementId' => Type::int(), 'elementSiteId' => Type::int(), diff --git a/src/translations/en/app.php b/src/translations/en/app.php index 24b841ef599..a9ef3ca1502 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -20,6 +20,7 @@ 'A server error occurred.' => 'A server error occurred.', 'A subpath is required for this filesystem.' => 'A subpath is required for this filesystem.', 'A template name cannot contain NUL bytes.' => 'A template name cannot contain NUL bytes.', + 'ARIA Label' => 'ARIA Label', 'Abandoned' => 'Abandoned', 'Abort the update' => 'Abort the update', 'Access the control panel when the system is offline' => 'Access the control panel when the system is offline', @@ -297,6 +298,7 @@ 'Choose' => 'Choose', 'City' => 'City', 'City/Town' => 'City/Town', + 'Class Name' => 'Class Name', 'Class' => 'Class', 'Clear Caches' => 'Clear Caches', 'Clear all' => 'Clear all', @@ -1299,6 +1301,7 @@ 'Relate {type} from a specific site?' => 'Relate {type} from a specific site?', 'Related To' => 'Related To', 'Related {type} Title' => 'Related {type} Title', + 'Relation ({ex})' => 'Relation ({ex})', 'Relations don’t store the selected site, so this should only be enabled if some {type} aren’t propagated to all sites.' => 'Relations don’t store the selected site, so this should only be enabled if some {type} aren’t propagated to all sites.', 'Release all jobs' => 'Release all jobs', 'Release job' => 'Release job', @@ -1439,6 +1442,7 @@ 'Send' => 'Send', 'Sender Name' => 'Sender Name', 'Sendmail Command' => 'Sendmail Command', + 'Separate multiple values with spaces.' => 'Separate multiple values with spaces.', 'Server Error' => 'Server Error', 'Server' => 'Server', 'Service Unavailable' => 'Service Unavailable', @@ -1576,6 +1580,7 @@ 'Tag group saved.' => 'Tag group saved.', 'Tag' => 'Tag', 'Tags' => 'Tags', + 'Target' => 'Target', 'Teal' => 'Teal', 'Team permissions can be managed from {link}.' => 'Team permissions can be managed from {link}.', 'Team permissions can be managed from {path} on a development environment.' => 'Team permissions can be managed from {path} on a development environment.', @@ -1749,6 +1754,7 @@ 'Time' => 'Time', 'Tip' => 'Tip', 'Tip:' => 'Tip:', + 'Title Text' => 'Title Text', 'Title' => 'Title', 'To complete the update, some changes must be made to your database.' => 'To complete the update, some changes must be made to your database.', 'To install this plugin with composer, copy the command above to your terminal.' => 'To install this plugin with composer, copy the command above to your terminal.', diff --git a/src/web/assets/cp/dist/cp.js b/src/web/assets/cp/dist/cp.js index bd26e4af6c3..e1c16f839fa 100644 --- a/src/web/assets/cp/dist/cp.js +++ b/src/web/assets/cp/dist/cp.js @@ -1,3 +1,3 @@ /*! For license information please see cp.js.LICENSE.txt */ -(function(){var __webpack_modules__={463:function(){Craft.Accordion=Garnish.Base.extend({$trigger:null,targetSelector:null,_$target:null,init:function(t){var e=this;this.$trigger=$(t),this.$trigger.data("accordion")&&(console.warn("Double-instantiating an accordion trigger on an element"),this.$trigger.data("accordion").destroy()),this.$trigger.data("accordion",this),this.targetSelector=this.$trigger.attr("aria-controls")?"#".concat(this.$trigger.attr("aria-controls")):null,this.targetSelector&&(this._$target=$(this.targetSelector)),this.addListener(this.$trigger,"click","onTriggerClick"),this.addListener(this.$trigger,"keypress",(function(t){var n=t.keyCode;n!==Garnish.SPACE_KEY&&n!==Garnish.RETURN_KEY||(t.preventDefault(),e.onTriggerClick())}))},onTriggerClick:function(){"true"===this.$trigger.attr("aria-expanded")?this.hideTarget(this._$target):this.showTarget(this._$target)},showTarget:function(t){var e=this;if(t&&t.length){this.showTarget._currentHeight=t.height(),t.removeClass("hidden"),this.$trigger.removeClass("collapsed").addClass("expanded").attr("aria-expanded","true");for(var n=0;n=this.settings.maxItems)){var e=$(t).appendTo(this.$tbody),n=e.find(".delete");this.settings.sortable&&this.sorter.addItems(e),this.$deleteBtns=this.$deleteBtns.add(n),this.addListener(n,"click","handleDeleteBtnClick"),this.totalItems++,this.updateUI()}},reorderItems:function(){var t=this;if(this.settings.sortable){for(var e=[],n=0;n=this.settings.maxItems?$(this.settings.newItemBtnSelector).addClass("hidden"):$(this.settings.newItemBtnSelector).removeClass("hidden"))}},{defaults:{tableSelector:null,noItemsSelector:null,newItemBtnSelector:null,idAttribute:"data-id",nameAttribute:"data-name",sortable:!1,allowDeleteAll:!0,minItems:0,maxItems:null,reorderAction:null,deleteAction:null,reorderSuccessMessage:Craft.t("app","New order saved."),reorderFailMessage:Craft.t("app","Couldn’t save new order."),confirmDeleteMessage:Craft.t("app","Are you sure you want to delete “{name}”?"),deleteSuccessMessage:Craft.t("app","“{name}” deleted."),deleteFailMessage:Craft.t("app","Couldn’t delete “{name}”."),onReorderItems:$.noop,onDeleteItem:$.noop}})},6872:function(){Craft.AssetImageEditor=Garnish.Modal.extend({$body:null,$footer:null,$imageTools:null,$buttons:null,$cancelBtn:null,$replaceBtn:null,$saveBtn:null,$focalPointBtn:null,$editorContainer:null,$straighten:null,$croppingCanvas:null,$spinner:null,$constraintContainer:null,$constraintRadioInputs:null,$customConstraints:null,canvas:null,image:null,viewport:null,focalPoint:null,focalPointInnerCircle:null,focalPointOuterCircle:null,focalPointPickedIndicator:null,grid:null,croppingCanvas:null,clipper:null,croppingRectangle:null,cropperHandles:null,cropperGrid:null,croppingShade:null,imageStraightenAngle:0,viewportRotation:0,originalWidth:0,originalHeight:0,imageVerticeCoords:null,zoomRatio:1,animationInProgress:!1,currentView:"",assetId:null,cacheBust:null,draggingCropper:!1,scalingCropper:!1,handleClicked:!1,draggingFocal:!1,focalPickedUp:!1,focalClicked:!1,cropperClicked:!1,previousMouseX:0,previousMouseY:0,shiftKeyHeld:!1,editorHeight:0,editorWidth:0,cropperState:!1,scaleFactor:1,flipData:{},focalPointState:!1,maxImageSize:null,lastLoadedDimensions:null,imageIsLoading:!1,mouseMoveEvent:null,croppingConstraint:!1,constraintOrientation:"landscape",showingCustomConstraint:!1,saving:!1,renderImage:null,renderCropper:null,_queue:null,init:function(t,e){var n=this;this._queue=new Craft.Queue,this.cacheBust=Date.now(),this.setSettings(e,Craft.AssetImageEditor.defaults),null===this.settings.allowDegreeFractions&&(this.settings.allowDegreeFractions=Craft.isImagick),Garnish.prefersReducedMotion()&&(this.settings.animationDuration=1),this.assetId=t,this.flipData={x:0,y:0},this.$container=$('').appendTo(Garnish.$bod),this.$body=$('
').appendTo(this.$container),this.$footer=$('