Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ jobs:
- name: Run PHPUnit (Bootstrap Admin Ui)
run: (cd src/BootstrapAdminUi/ && vendor/bin/phpunit)

- name: Setup Node.js for Bootstrap Admin UI tests
if: matrix.php == '8.3' && matrix.symfony == '^7.4'
uses: actions/setup-node@v4
with:
node-version: '24'

- name: Run JavaScript tests (Bootstrap Admin UI)
if: matrix.php == '8.3' && matrix.symfony == '^7.4'
working-directory: src/BootstrapAdminUi
run: |
yarn install --frozen-lockfile --non-interactive
yarn test

- name: "Restrict packages' versions (Twig Hooks)"
run: |
(cd src/TwigHooks/ && composer config --no-plugins allow-plugins.symfony/runtime true)
Expand Down
2 changes: 2 additions & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
## 🎨 Bootstrap Admin UI

* [Getting started](bootstrap-admin-ui/getting-started.md)
* Components
* [Copy to clipboard](bootstrap-admin-ui/components/copy-to-clipboard.md)

## 🍀 Twig Extra

Expand Down
189 changes: 189 additions & 0 deletions docs/bootstrap-admin-ui/components/copy-to-clipboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Copy to clipboard

Use `copy_to_clipboard` for a standalone copy button, or `copyable` to display a value or custom inline content next to the button. Both copy the explicit `value` prop, never the rendered content.

## Installation

Install Bootstrap Admin UI before using the component:

```bash
composer require sylius/bootstrap-admin-ui
```

The components use Symfony UX Twig Component, Stimulus, UX Icons and the Bootstrap Admin UI/Tabler styles. Bootstrap CSS alone does not provide all the default button styles.

With the standard Bootstrap Admin UI layout and compiled assets, the controller is registered by the package's `symfony_ux` entrypoint. If your application starts its own Stimulus application, follow [JavaScript integration](#javascript-integration) below instead.

## Usage

Pass a literal value with the `value` prop:

```twig
<span>ABC-123</span>

<twig:sylius_bootstrap_admin_ui:copy_to_clipboard
value="ABC-123"
/>
```

For a Twig expression, prefix the prop with `:`:

```twig
<span>{{ item.code }}</span>

<twig:sylius_bootstrap_admin_ui:copy_to_clipboard
:value="item.code"
/>
```

### Display a value with its copy button

```twig
<twig:sylius_bootstrap_admin_ui:copyable :value="item.code" />
```

For a badge, a formatted ID, a truncated label or an email link, provide custom content:

```twig
<twig:sylius_bootstrap_admin_ui:copyable :value="customer.email">
<a href="mailto:{{ customer.email }}">{{ customer.email }}</a>
</twig:sylius_bootstrap_admin_ui:copyable>
```

The body overrides the `content` block. The copy button remains a sibling of the content, not a child of the link. Only `customer.email` is copied, independently of the displayed text. Use inline content and do not place either component inside another button or link.

The default displayed value is escaped and is not automatically translated. Translate or format the display in the content block when needed; explicitly pass a translated `value` if that is also what should be copied.

### Missing values and disabling copying

Omitting `value`, passing `null`, or passing an empty string disables the button. Custom content remains visible. Both numeric `0` and string `"0"` are copyable. Pass IDs with significant leading zeros as strings.

Both components also accept `disabled` to explicitly disable the button. They do not read from inputs or watch your application data: update the component's props when re-rendering dynamic values.

### Twig Hooks

Both components can be rendered directly as Twig Hook components. They consume the injected hook metadata without rendering it as an HTML attribute. Pass the value explicitly through the props:

```yaml
sylius_twig_hooks:
hooks:
'app.example':
copy:
component: 'sylius_bootstrap_admin_ui:copy_to_clipboard'
props:
value: '@=_context.item.getCode()'
```

Use `sylius_bootstrap_admin_ui:copyable` instead to include the default text display. For custom markup, use a hook template containing the component and its body.

### Button attributes

Additional component attributes are forwarded to the button. Default button classes are preserved when adding custom classes:

```twig
<twig:sylius_bootstrap_admin_ui:copy_to_clipboard
:value="item.code"
class="btn-sm"
data-test-copy-code
/>
```

For `copyable`, ordinary attributes apply to the outer container. Prefix attributes with `button:` to apply them to the copy button:

```twig
<twig:sylius_bootstrap_admin_ui:copyable
:value="item.code"
class="text-nowrap"
button:class="custom-copy-button"
button:data-test-copy-code
/>
```

Both components accept `button_class` to **replace** the default classes, while `class` on the standalone button (or `button:class` on `copyable`) adds classes. For example, use `button_class="btn btn-sm btn-outline-secondary"` for standard Bootstrap button styling without Tabler's ghost-button classes.

### Translations

The component uses these translation keys by default:

- `sylius.ui.copy_to_clipboard`
- `sylius.ui.copied_to_clipboard`
- `sylius.ui.unable_to_copy_to_clipboard`

Override those keys in your application's translation files, or pass alternative translation keys with the label props:

```twig
<twig:sylius_bootstrap_admin_ui:copy_to_clipboard
:value="item.code"
copy_label="app.ui.copy_code"
copied_label="app.ui.code_copied"
error_label="app.ui.unable_to_copy_code"
/>
```

### Feedback and dynamic content

The button uses a native `title`, not a Bootstrap tooltip, so no tooltip initialization is needed when content is inserted dynamically. The controller connects through Stimulus as usual.

During a copy, repeated clicks are ignored and the button exposes `aria-busy` without disabling it or taking away keyboard focus. Success changes the icon and announces the translated message through a live region. Failure also displays the translated error text. Feedback resets after two seconds; a new copy replaces previous feedback. Disconnecting cancels pending UI updates and timers, not the browser's clipboard operation itself.

## JavaScript integration

Register the controller **once in the application's existing Stimulus application**. Do not start a second application just for this component. Both Twig components use the identifier `copy-to-clipboard`.

### AssetMapper

Follow the [AssetMapper setup](../../getting-started.md#using-assetmapper) to disable the package's `symfony_ux` entrypoint when starting your own Stimulus application. Expose the controller source directory to AssetMapper:

```yaml
# config/packages/asset_mapper.yaml
framework:
asset_mapper:
paths:
'%kernel.project_dir%/vendor/sylius/bootstrap-admin-ui/assets/controllers': 'sylius/bootstrap-admin-ui/controllers'
```

Add a local importmap entry (this does not install another JavaScript package):

```bash
php bin/console importmap:require '@sylius/bootstrap-admin-ui/copy-to-clipboard' --path=./vendor/sylius/bootstrap-admin-ui/assets/controllers/copy-to-clipboard-controller.js
```

In the module where your application already starts Stimulus:

```js
import { startStimulusApp } from '@symfony/stimulus-bundle';
import CopyToClipboardController from '@sylius/bootstrap-admin-ui/copy-to-clipboard';

const app = startStimulusApp();
app.register('copy-to-clipboard', CopyToClipboardController);
```

If `app` is already exported by another module, import and reuse it instead of calling `startStimulusApp()` again. The application's importmap must also provide `@hotwired/stimulus`, as in a normal Symfony Stimulus installation.

### Webpack Encore / an existing Sylius Stimulus application

Import the controller source and register it on your existing application. From a project-level `assets/bootstrap.js`, the import is:

```js
import CopyToClipboardController from '../vendor/sylius/bootstrap-admin-ui/assets/controllers/copy-to-clipboard-controller.js';

app.register('copy-to-clipboard', CopyToClipboardController);
```

Here `app` is your existing Stimulus application. Adjust the relative import path to your entrypoint location and rebuild the application's assets. Do not import the package's entire `symfony_ux` entrypoint into an application that already starts Stimulus.

### Sylius compatibility

Match the Bootstrap Admin UI package version to the target Sylius version before installing it. This branch requires `sylius/twig-hooks ^0.12`, whereas the Sylius 2.1 and 2.2 AdminBundle branches require `^0.8` and `^0.9` respectively. These constraints do not overlap; controller registration alone does not solve that Composer incompatibility.

Reusing this functionality in those versions requires a compatible backport or dependency alignment. It does not require replacing the application's admin layout. When integrating the templates selectively, also configure the anonymous component prefix, UX Icons and the translations rather than importing the whole Bootstrap Admin UI layout configuration.

## Browser requirements

Copying uses the browser's Clipboard API. This API requires a secure context, which normally means HTTPS in production or localhost during development.

Clipboard permissions and embedding policies can still prevent copying. The components report failure rather than falling back to deprecated clipboard APIs. Values passed to the component are present in the page's HTML; do not pass secrets or data the current user is not authorized to see.

## Tests

From `src/BootstrapAdminUi`, run `vendor/bin/phpunit` for the rendering and Twig Hooks tests, and `yarn test` for the controller unit tests using Node.js 24 and the existing JavaScript dependencies. The latter use DOM doubles; actual browser permissions and assistive-technology behavior still require browser-level testing.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
static targets = ['button', 'copyIcon', 'successIcon', 'errorIcon', 'status'];

static values = {
value: String,
copiedLabel: String,
errorLabel: String,
};

connect() {
this.pendingCopy = null;
this.reset();
}

async copy() {
if (this.pendingCopy || this.buttonTarget.disabled || !this.hasValueValue || this.valueValue === '') {
return;
}

this.reset();
const operation = {};
this.pendingCopy = operation;
this.buttonTarget.setAttribute('aria-busy', 'true');

try {
await navigator.clipboard.writeText(this.valueValue);

if (this.pendingCopy === operation) {
this.showFeedback(this.successIconTarget, this.copiedLabelValue);
}
} catch (error) {
if (this.pendingCopy === operation) {
this.showFeedback(this.errorIconTarget, this.errorLabelValue, true);
}
} finally {
if (this.pendingCopy === operation) {
this.pendingCopy = null;
this.buttonTarget.removeAttribute('aria-busy');
}
}
}

disconnect() {
this.pendingCopy = null;
clearTimeout(this.resetTimer);

if (this.hasButtonTarget) {
this.buttonTarget.removeAttribute('aria-busy');
}
}

showFeedback(icon, message, isError = false) {
this.reset();
this.copyIconTarget.classList.add('d-none');
icon.classList.remove('d-none');
this.statusTarget.classList.toggle('visually-hidden', !isError);
this.statusTarget.textContent = message;

this.resetTimer = setTimeout(() => this.reset(), 2000);
}

reset() {
clearTimeout(this.resetTimer);
this.resetTimer = null;

this.copyIconTarget.classList.remove('d-none');
this.successIconTarget.classList.add('d-none');
this.errorIconTarget.classList.add('d-none');
this.statusTarget.classList.add('visually-hidden');
this.statusTarget.textContent = '';
}
}
3 changes: 2 additions & 1 deletion src/BootstrapAdminUi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production"
"build": "encore production",
"test": "node --test tests/JavaScript/*.test.mjs"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{% props
value = null,
hookableMetadata = null,
disabled = false,
button_class = 'btn btn-icon btn-sm btn-ghost-secondary',
copy_label = 'sylius.ui.copy_to_clipboard',
copied_label = 'sylius.ui.copied_to_clipboard',
error_label = 'sylius.ui.unable_to_copy_to_clipboard'
%}

<span {{ stimulus_controller('copy-to-clipboard', {
value: value,
copiedLabel: copied_label|trans,
errorLabel: error_label|trans,
}) }}>
<button
{{ attributes
.defaults(stimulus_action('copy-to-clipboard', 'copy'))
.defaults({
type: 'button',
class: button_class,
disabled: disabled or value is null or value is same as(''),
'aria-label': copy_label|trans,
title: copy_label|trans,
'data-copy-to-clipboard-target': 'button',
})
}}
{{ sylius_test_html_attribute('copy-to-clipboard') }}
>
{{ ux_icon('tabler:copy', {
class: 'icon',
'aria-hidden': 'true',
'data-copy-to-clipboard-target': 'copyIcon',
}) }}
{{ ux_icon('tabler:check', {
class: 'icon d-none text-success',
'aria-hidden': 'true',
'data-copy-to-clipboard-target': 'successIcon',
}) }}
{{ ux_icon('tabler:alert-circle', {
class: 'icon d-none text-danger',
'aria-hidden': 'true',
'data-copy-to-clipboard-target': 'errorIcon',
}) }}
</button>

<span class="visually-hidden small text-danger ms-1" role="status" aria-live="polite" aria-atomic="true" data-copy-to-clipboard-target="status"></span>
</span>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% props
value = null,
hookableMetadata = null,
disabled = false,
button_class = 'btn btn-icon btn-sm btn-ghost-secondary',
copy_label = 'sylius.ui.copy_to_clipboard',
copied_label = 'sylius.ui.copied_to_clipboard',
error_label = 'sylius.ui.unable_to_copy_to_clipboard'
%}

<span {{ attributes.defaults({class: 'd-inline-flex align-items-center gap-1'}) }}>
{% block content %}{{ value }}{% endblock %}

{{ component('sylius_bootstrap_admin_ui:copy_to_clipboard', attributes.nested('button').all()|merge({
value: value,
disabled: disabled,
button_class: button_class,
copy_label: copy_label,
copied_label: copied_label,
error_label: error_label,
})) }}
</span>
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
imports:
- { resource: '../../../../../config/app.php' }

sylius_twig_hooks:
hooks:
'app.copy_to_clipboard':
copy:
component: 'sylius_bootstrap_admin_ui:copy_to_clipboard'
props:
value: '@=_context.value'
'app.copyable':
copy:
component: 'sylius_bootstrap_admin_ui:copyable'
props:
value: '@=_context.value'
Loading
Loading