diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f1e58ff4..4816495d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,17 +15,12 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4', '8.5'] - laravel: ['10.*', '11.*', '12.*', '13.*'] + php: ['8.3', '8.4', '8.5'] + laravel: ['12.*', '13.*'] exclude: - - php: '8.2' - laravel: '12.*' - - php: '8.2' - laravel: '13.*' + - php: '8.3' laravel: '13.*' - - php: '8.5' - laravel: '10.*' name: PHP ${{ matrix.php }} – Laravel ${{ matrix.laravel }} @@ -43,19 +38,8 @@ jobs: run: | composer require "illuminate/support:${{ matrix.laravel }}" "illuminate/database:${{ matrix.laravel }}" --no-interaction --no-update - - name: Drop wire-boost on Laravel 10 (laravel/mcp requires Laravel 11+) - if: matrix.laravel == '10.*' - run: | - composer remove nyoncode/wire-boost --no-interaction --no-update - composer remove laravel/mcp --dev --no-interaction --no-update - - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress - name: Run tests - if: matrix.laravel != '10.*' run: vendor/bin/pest - - - name: Run tests (excluding boost on Laravel 10) - if: matrix.laravel == '10.*' - run: vendor/bin/pest --exclude-testsuite="Boost Unit,Boost Feature" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..28ca03d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md + + + +## Building this package with laravel-package-toolkit + +This package extends `PackageServiceProvider` and describes itself in one method, +`configure(Packager $packager)`. Before writing or changing that method, read the complete +API reference that ships with the installed release: + +[vendor/nyoncode/laravel-package-toolkit/ai/AGENTS.md](vendor/nyoncode/laravel-package-toolkit/ai/AGENTS.md) + +It covers every `hasX()` builder, which resources load vs. publish vs. both, how paths +resolve relative to the provider file, the publish-tag format, and the mistakes that fail +silently. Prefer it over recalling the API — it matches the version in `composer.lock`. + + diff --git a/AI_CODING_STANDARD.md b/AI_CODING_STANDARD.md index 0faa8dbf..42f030a3 100644 --- a/AI_CODING_STANDARD.md +++ b/AI_CODING_STANDARD.md @@ -146,7 +146,7 @@ across the `TypeCatalog` is missing a summary. | Interfaces | `HasLabel`, `HasIcon`, `CanDelete`, `CanSort` | | Traits | `InteractsWithLabel`, `InteractsWithIcon`, `CanDelete`, `CanSort` | | Actions | `CreateUser`, `DeleteUser`, `SyncPermissions`, `GenerateColumns` | -| Services | `TranslationService`, `AssetManager`, `NavigationManager` | +| Services | `TranslationService`, `NavigationManager`, `IconManager` | | Managers | `PluginManager`, `ComponentManager`, `ThemeManager` | ## Directory Structure @@ -272,15 +272,78 @@ pattern to eliminate (per-column Htmlable skeletons; see the plans below). Rule speed are the *same* requirement: self-render, done once. Record-invariant markup MUST be resolved once, never re-rendered per row. -**Reference implementation:** `Column::renderCellFast()` — resolve `tables.columns.text` -once into a skeleton with a content token, splice `e($state)` per row (measured -byte-identical to `renderCell()` and ~5× cheaper; one view render per column, not V×R). -It falls back to the full `renderCell()` when the skeleton cannot apply — a per-record -url/copy/description-closure (`isCellSkeletonable()`), or a subclass that overrides -`renderCell` with its own view (`supportsCellSkeleton()`). Any new fast path MUST carry -the same two guards: a **byte-identity test** vs the classic render across escaping / -edge-whitespace / unicode / html / empty content, and the **render-count fuse** proving -zero per-row view renders. +**Canonical owner:** `Foundation\View\Skeleton` (core, `Htmlable`) — compile a +rendered template once, `fill()` per row in one `strtr()` pass. Use it rather than a +local token-and-`str_replace`; `strtr` is also the correct primitive, because it does +not re-examine what it just substituted. + +**Always `Htmlable`, always Blade — no exceptions.** Markup lives in a `.blade.php` +template, and PHP produces it only through an `Htmlable` owner (`Skeleton`, `HtmlString`, +a component's own `toHtml()` / `getXHtml()`). Raw HTML concatenated from PHP strings is +never acceptable — not for speed, not for a single tag, not when the output is +byte-identical and the suite is green. + +**The markup MUST stay in a Blade template. This is not negotiable.** A skeleton is +compiled from `view(...)->render()` — it is a template *rendered once*, never a tag +soup concatenated from PHP strings. Building `'…'` in a `@php` +preamble, a helper or a class body is a violation even when the output is byte-identical +and even when it is faster to write: it destroys the `vendor:publish` override point, +puts markup where no Blade tooling, formatter or reviewer looks for it, and splits one +element's markup across two languages. What moves out of the loop is the **render**, not +the template. + +```php +// WRONG — markup assembled in PHP, no override point, invisible to Blade tooling. +$cell = Skeleton::compile('', 'key'); + +// RIGHT — the partial stays the one source of the markup; only the render moves. +$cell = Skeleton::compile( + view('wire-table::tables.partials.selection-cell', [ + 'cellPadding' => $this->getCellPadding(), + 'keyJs' => Skeleton::slot('keyJs'), // the hole, handed to the template + ])->render(), + 'keyJs', +); +``` + +Two consequences worth stating, because they are what makes the Blade version as cheap +as the PHP one: + +- **Slots are passed *into* the view as data**, so the template decides where each + per-record value lands and under which encoding — which is what keeps "one slot, one + position, one encoding" a property of the template rather than of the caller. +- **Whitespace between tags is the template's job.** Tags that must touch (`>…<` with + no run between them) are written touching in the Blade; whitespace *between + attributes* is free and stays laid out. A skeleton is not a licence to minify by + moving markup into PHP. + +The same rule covers any table/row/cell chrome resolved once per render: put it in a +partial and render it once (`tables.partials.selection-cell`), do not inline it as a +string. Values a template needs that come from a density/variant map (padding, +alignment) get a **getter on the owning object** (`Table::getCellPadding()`), so the +partial and the parent view cannot drift. + +**Reference implementation:** `Column::renderCellFast()` — resolves +`tables.columns.text` once into a `Skeleton` and splices per-record values per row +(byte-identical to `renderCell()`, one view render per cell *shape*, not V×R). The +rule that makes it safe is **one slot, one position, one encoding**: the caller hands +each value in already encoded exactly as the template would have encoded it there +(`e()` inside an attribute, raw for markup). A value appearing twice under two +encodings is the boundary where this stops being cheap — see the inline-edit +evaluation in the plan. + +A slot substitutes a **value, never a shape**. When a record changes the structure (a +url on one row, none on the next), that is a second skeleton, cached per shape — +O(shapes), not O(rows). The only remaining fallback is a subclass that overrides +`renderCell` with its own view (`supportsCellSkeleton()`). + +Any new fast path MUST carry the same two guards: a **byte-identity test** vs the +classic render across escaping / edge-whitespace / unicode / html / empty content +*and* hostile per-record values, and the **render-count fuse** proving zero per-row +view renders. Client-side there is a third: the **payload fuse** +(`TablePayloadFuseTest`) budgets bytes, whitespace text nodes and morph markers per +row — the morph walks every node, and a run of whitespace between tags is one node +however short you make it. Pick the mechanism by *what varies per row*: **content columns** (structure fixed, only the value changes) use the **skeleton splice** above; **state-driven columns** @@ -434,15 +497,15 @@ else document.addEventListener('alpine:init', register) it is already too late for the page it fires on. **Delivery is the other half.** Core interaction controllers must be in the initial -document — a package declares them to `Foundation\Assets\AssetManager` from its own -provider, and the app adds one `@wireStackScripts` to its layout. Downstream packages +document — a package declares them with `hasAssets(entries: [Bundle::make(...)])` in +its own `configure()`, and the app adds one `@wireStackScripts` to its layout. Downstream packages push their own registration; core never learns they exist. Only the always-present case is safe on the cached Back/Forward path, where Livewire does **not** wait for newly injected head scripts before initialising Alpine. -**Lazy-load bodies, never registrators.** Lazy is for heavy, optional assets (rich text, -charts) via `loadedOnRequest()`; the registrar inside such a bundle is still -unconditional. A lazily delivered *registration mechanism* is precisely the bug above. +**Lazy-load bodies, never registrators.** Lazy is for heavy, optional assets (rich +text): leave them out of `entries:` and have the surface deliver them; the registrar +inside such a bundle is still unconditional. A lazily delivered *registration mechanism* is precisely the bug above. Verify with `verify-spa-navigate` plus the drivers for whatever the bundle touches. See `architecture/plans/js-asset-registration.md` and ADR diff --git a/AI_COMPONENT_CATALOG.md b/AI_COMPONENT_CATALOG.md index ff8686d1..a6f4ba33 100644 --- a/AI_COMPONENT_CATALOG.md +++ b/AI_COMPONENT_CATALOG.md @@ -25,6 +25,7 @@ Use these before creating local field/column/action helpers: - `BelongsToComponent` - `CanBeLive` - `CanBeReadOnly` +- `CanBeTyped` - `HasAuthorization` - `HasColor` - `HasColumnSpan` @@ -66,15 +67,14 @@ Colors/icons: - `Foundation\Icons\DefaultIconSet` - `Foundation\Icons\HeroiconsOutlineSet` -Browser assets (canonical owner of every package's JS bundle): +Browser assets (the registry, the URL and the tag belong to the toolkit's +`PackageAssets`; what is core's is the declaration): -- `Foundation\Assets\AssetManager` — container singleton; `register()` per package, - `getScripts()` / `renderScripts()` behind the `@wireStackScripts` directive, - `get()` / `url()` for a surface emitting its own tag -- `Foundation\Assets\Js` — one bundle: `make($id, $filesystemPath)` plus `module()`, - `defer()`, `navigateTrack()`, `navigateOnce()`, `loadedOnRequest()` -- `Foundation\Assets\Contracts\Asset` -- `Exceptions\AssetRegistrationException` +- `Foundation\Assets\Bundle` — `make($shippedFile)` for a declaration every package + shares (classic/IIFE, no `defer`, `data-navigate-once`) and + `servedByRoute($package)` for the `hasAssetFallback()` resolver +- `Foundation\View\FloatingAssets` — the dropdown bundle's URL, by the name a dozen + partials already ask for it Foundation Blade components: @@ -548,9 +548,8 @@ Views: Assets: - `packages/sortable/resources/js/sortable.js` → `packages/sortable/dist/wire-sortable.js` - (`npm run build:sortable-assets`; SortableJS compiled in), registered with the core - `AssetManager` as `wire-sortable`/`sortable` and served by the `wire-sortable.asset` - route + (`npm run build:sortable-assets`; SortableJS compiled in), declared as a toolkit + asset entry with the `wire-sortable.asset` route behind it as fallback ## Test Locations diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c638b3..a318d9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,32 @@ All notable changes to the Wire ecosystem will be documented in this file. +## [1.17.0] + +### Added +- **The TipTap editor speaks Czech, and opens on a pre-formatted document — `->default()`.** Its toolbar tooltips were bare `__('Bold')` keys, which only an *app-level* translation file could ever answer: a Czech app shipped a fully translated form with an English editor bolted into the middle of it, and the two strings that live inside the JS bundle — the link and image `prompt()` titles — could not be translated at all. All of it now resolves from the package's own vocabulary (`wire-forms::fields.editor.*`, `en` + `cs`), including the prompt titles, which are read in PHP and handed to the editor through its Alpine config rather than being hardcoded in the bundle; the group is named `editor` rather than `tiptap` because **RichEditor and MarkdownEditor title their toolbars from the very same keys** — one vocabulary for all three editors, so they read alike in every locale and a reworded button is reworded once. RichEditor's link prompt moved from `prompt('{{ __('Enter URL') }}')` to `@js()`, which hex-escapes both quote characters: the old form rendered an apostrophe as `'`, and a locale whose wording contains one would have closed the JS string and, with it, the `x-data` attribute around it. Headings read as *Heading 2* / *Nadpis 2* rather than `H2` — the glyph on the button stays `H1`/`H2`/`H3` in every locale, since those are symbols, not words. Separately, a starting template is now the canonical `->default()` and **not** a second editor-only method: the form runtime already seeds it into the state bag, and the field additionally hands it to the editor, which applies it when the bound value is empty and pushes the parsed document back into Livewire — so a host that never seeded (a `null` column, a hand-bound property) still opens on the template, and saving an untouched form stores it rather than nothing. The default is markup, so `'

Zápis z porady

Nějaký text

'` arrives formatted; under `->outputJson()` it may be a JSON document string *or* the same HTML, which is where the old code dropped it — a non-JSON value was parsed with a `catch { return {} }` and became an empty editor. Re-opening a document the user deliberately cleared does not bring the default back: an emptied editor stores `

`, not `''`. Browser-verified by `workbench/scripts/verify-tiptap-split.mjs` (14/14) against a new `/previews/field-tiptap-default`, which reads the seeded document out of Livewire's state, not just off the screen. See `docs/forms/fields/tiptap-editor.md`. +- **JS bundles are served as static files out of `public/vendor`, and get there by themselves.** Serving a bundle from a package route only works when the request reaches PHP, and a very common nginx layout answers `.js` from a `try_files $uri =404` block that never forwards it — the same block 404s Livewire's own `/livewire/livewire.js`. On shared hosting that block is frequently not the application's to change, so a delivery mode whose correctness depends on a vhost the app cannot edit is not a delivery mode. It is now files: the first page render after a deploy mirrors each package's `dist/` into `public/vendor/` and `@wireStackScripts` emits those paths. No command, no composer hook, no config key — and nothing for an app that already worked to do. The mirror is **incremental** (only a file missing or older than the shipped one is copied, so steady state is a handful of `stat` calls and zero writes, and an upgrade is one copy per changed bundle on one request), **atomic** (copies land through a temp file and `rename()`, so a browser fetching mid-copy never gets a truncated bundle — which would be a syntax error taking every controller in it down), **whole-directory** (TipTap's entry imports `./chunk-.js`, which the browser fetches itself and PHP is never asked to resolve, so a mirror driven only by registered bundles would break the editor), and **lazy** rather than booted (mirroring from `boot()` would put a directory walk on every queue job and API route that will never emit a ` - + - + ` and serves them as static +files, cache-busted by file modification time. The one thing your app decides is +*where* they are emitted — put ```blade @wireStackScripts @@ -44,6 +45,10 @@ the initial document, which is what keeps them working across `wire:navigate` (including the cached Back/Forward path). Pass a package name — `@wireStackScripts('wire-table')` — to emit only one package's bundles. +`php artisan vendor:publish --tag=laravel-assets --force` does the same copy ahead +of time, which moves it off the first request after a deploy — useful, never +required. There is no config key either way. + Full explanation in [Getting Started → JavaScript Assets](getting-started.md#javascript-assets). ## Core diff --git a/docs/core/schema/layout/wizard.md b/docs/core/schema/layout/wizard.md index 8798abbc..b6fa63f0 100644 --- a/docs/core/schema/layout/wizard.md +++ b/docs/core/schema/layout/wizard.md @@ -56,12 +56,46 @@ Multiple wizards on one host are addressed by name — give each a name (`Wizard::make('signup')`) so its steps validate independently; an unnamed wizard resolves to the first one in the schema. +## Handing The Navigation Elsewhere + +`navigation(false)` renders the wizard without its Previous / Next row, for a +surface that wants those controls in its own chrome — a modal footer, a page +toolbar — so two navigations do not sit on screen at once: + +```php +Wizard::make('category') + ->navigation(false) // [tl! focus] + ->schema([ + Step::make('Name')->schema([TextInput::make('label')->required()]), + Step::make('Detail')->schema([TextInput::make('note')]), + ]) +``` + +The wizard still owns the step state; the outer surface mirrors and steps it over +two window events, because a driving footer is a *sibling* subtree and a bubbling +event would never reach it: + +- `wire-wizard-state` — published by the wizard whenever its step, total or + validating flag changes: `{ wizard, step, total, validating }`. +- `wire-wizard-navigate` — sent to the wizard to move: `{ wizard, direction }` + where direction is `'next'` or `'previous'`. `'next'` runs the same per-step + validation the built-in button does, so an external control gates identically. + +Both are scoped by `wizard` — the wizard's name, `null` when unnamed. Name the +wizard whenever two can be on screen at once, or they share an empty scope. + +A [`Select`'s option modal](../../../forms/fields/select.md#a-full-form-not-a-field-list) +does this for you: put a `navigation(false)` wizard in `createOptionForm()` and +the modal footer takes over, showing Back / Next until the last step and the +submit button only there. + ## Methods | Method | On | Description | |--------|----|-------------| | `activeStep(int)` | `Wizard` | Zero-based index of the step shown first | | `skippable()` | `Wizard` | Allow jumping to any step from the indicator | +| `navigation(bool)` | `Wizard` | Render without the built-in Previous / Next row, for an outer surface to drive | | `description(string)` | `Step` | Secondary line under the step label | | `icon(string\|Icon)` | `Step` | Step icon | | `columns(int)` | `Step` | Column grid for the step's child schema | diff --git a/docs/cs/configuration.md b/docs/cs/configuration.md index a0ba5980..963e9ae2 100644 --- a/docs/cs/configuration.md +++ b/docs/cs/configuration.md @@ -32,9 +32,10 @@ Potřebujete jen tagy balíčků, které jste nainstalovali. ## JavaScriptové assety -Assety se nekonfigurují a není co publikovat: každý balíček servíruje své -předsestavené bundly z vlastní routy, s cache-bustingem podle času poslední změny -souboru. Jediné, o čem rozhoduje vaše aplikace, je *kde* se vypíšou — dejte +Není co konfigurovat ani co publikovat: každý balíček si své předsestavené bundly +zkopíruje do `public/vendor/` a servíruje je jako statické soubory, +s cache-bustingem podle času poslední změny souboru. Jediné, o čem rozhoduje vaše +aplikace, je *kde* se vypíšou — dejte ```blade @wireStackScripts @@ -45,6 +46,10 @@ v úvodním dokumentu, což je přesně to, co je udrží funkční napříč `w (včetně cesty cachovaného Zpět/Vpřed). Předáním jména balíčku — `@wireStackScripts('wire-table')` — vypíšete jen bundly jednoho balíčku. +`php artisan vendor:publish --tag=laravel-assets --force` udělá tutéž kopii dopředu, +čímž ji sundá z prvního requestu po nasazení — užitečné, nikdy povinné. Konfigurační +klíč k tomu tak jako tak žádný není. + Podrobné vysvětlení v [Začínáme → JavaScriptové assety](getting-started.md#javascriptove-assety). ## Core diff --git a/docs/cs/core/schema/layout/wizard.md b/docs/cs/core/schema/layout/wizard.md index 23342c87..95c3b7c1 100644 --- a/docs/cs/core/schema/layout/wizard.md +++ b/docs/cs/core/schema/layout/wizard.md @@ -57,12 +57,46 @@ Více wizardů na jednom hostiteli se adresuje podle názvu — dejte každému (`Wizard::make('signup')`), aby jeho kroky validovaly nezávisle; nepojmenovaný wizard se resolvuje na první ve schématu. +## Předání navigace jinam + +`navigation(false)` vykreslí wizard bez jeho řádku Previous / Next, pro plochu, +která chce ty ovládací prvky ve vlastním chrome — patička modalu, toolbar +stránky — aby na obrazovce neseděly dvě navigace naráz: + +```php +Wizard::make('category') + ->navigation(false) // [tl! focus] + ->schema([ + Step::make('Name')->schema([TextInput::make('label')->required()]), + Step::make('Detail')->schema([TextInput::make('note')]), + ]) +``` + +Wizard dál vlastní stav kroku; vnější plocha ho zrcadlí a posouvá přes dvě window +události, protože řídící patička je *sourozenecký* podstrom a bublající událost by +se k ní nikdy nedostala: + +- `wire-wizard-state` — publikuje wizard, kdykoli se změní jeho krok, celkový + počet nebo příznak validace: `{ wizard, step, total, validating }`. +- `wire-wizard-navigate` — pošle se wizardu pro posun: `{ wizard, direction }`, + kde direction je `'next'` nebo `'previous'`. `'next'` spustí tu samou validaci + po krocích jako vestavěné tlačítko, takže externí ovládání gatuje stejně. + +Obě jsou zúžené podle `wizard` — názvu wizardu, `null` u nepojmenovaného. Wizard +pojmenujte vždy, když můžou být dva na obrazovce naráz, jinak sdílí prázdný scope. + +[Option modal `Select`u](../../../forms/fields/select.md#plnohodnotny-formular-ne-seznam-poli) +tohle udělá za vás: dejte do `createOptionForm()` wizard s `navigation(false)` a +patička modalu převezme řízení — zobrazí Back / Next až do posledního kroku a +tlačítko odeslání jen tam. + ## Metody | Metoda | Na | Popis | |--------|----|-------------| | `activeStep(int)` | `Wizard` | Index (od nuly) kroku zobrazeného jako první | | `skippable()` | `Wizard` | Povolit skok na jakýkoli krok z indikátoru | +| `navigation(bool)` | `Wizard` | Vykreslit bez vestavěného řádku Previous / Next, k řízení vnější plochou | | `description(string)` | `Step` | Sekundární řádek pod labelem kroku | | `icon(string\|Icon)` | `Step` | Ikona kroku | | `columns(int)` | `Step` | Sloupcový grid pro dětské schéma kroku | diff --git a/docs/cs/forms/custom-fields.md b/docs/cs/forms/custom-fields.md index 8198196e..f0a1bfdf 100644 --- a/docs/cs/forms/custom-fields.md +++ b/docs/cs/forms/custom-fields.md @@ -735,23 +735,30 @@ vypsat dvakrát (per-surface include plus [`@wireStackScripts`](../getting-started.md#javascriptove-assety)) a prohlížeč ho oba dva krát spustí. -Pokud váš balíček dodává víc než občasné těžké pole, deklarujte bundle sdílenému -`AssetManageru` z bootu vlastního service provideru místo pouhého per-surface -includu — `@wireStackScripts` ho pak vypíše vedle vlastních bundlů Wire: +Pokud váš balíček dodává víc než občasné těžké pole, deklarujte bundle v +`configure()` vlastního balíčku místo pouhého per-surface includu — +`@wireStackScripts` ho pak vypíše vedle vlastních bundlů Wire: ```php -use NyonCode\WireCore\Foundation\Assets\AssetManager; -use NyonCode\WireCore\Foundation\Assets\Js; +use NyonCode\WireCore\Foundation\Assets\Bundle; -app(AssetManager::class)->register([ - Js::make('my-field', __DIR__.'/../dist/my-field.js')->navigateTrack(), -], 'my-package'); +$packager + ->hasAssets('dist', entries: [ + Bundle::make('my-field.js'), + ]) + ->hasAssetFallback(Bundle::servedByRoute('my-package')); ``` -`Js::make()` bere id bundlu a **filesystemovou** cestu (odtud pochází cache-buster -`?id=`) a URL si vyřeší z pojmenované routy `{package}.asset` vašeho -balíčku. Těžká těla držte mimo stránky, které je nepotřebují, pomocí -`->loadedOnRequest()` — ale nikdy ne malý controller, který komponentu registruje. +Entries se klíčují **jménem dodávaného souboru** relativně k adresáři assetů. +`Bundle::make()` deklaruje to, čím každý bundle Wire je — klasický (nemodulový) +skript, protože top-level deklarace ES modulu se nikdy nedostanou na `window` a +vaše registrace by tiše neudělala nic. `hasAssetFallback()` udrží tag naživu tam, +kde do `public/` nejde zapisovat, tím že ukáže na vlastní routu +`{package}.asset` vašeho balíčku. + +Těžká těla držte mimo stránky, které je nepotřebují, tak že je vynecháte z +`entries:` a necháte je dodat pole per-surface — ale nikdy ne malý controller, +který komponentu registruje. --- diff --git a/docs/cs/forms/fields/date-time-picker.md b/docs/cs/forms/fields/date-time-picker.md index 0ba59866..6819fa06 100644 --- a/docs/cs/forms/fields/date-time-picker.md +++ b/docs/cs/forms/fields/date-time-picker.md @@ -96,6 +96,49 @@ DateTimePicker::make('date') > uložená hodnota zůstává beze změny. Ctí ho vlastní picker; formát zobrazení > nativního inputu patří prohlížeči a locale uživatele. +## Psaní z klávesnice + +Trigger je textové pole, ne tlačítko: hodnotu lze napsat, nejen vybrat. Napsaný +text se čte zpět stejným formátem, jakým se zobrazuje — `displayFormat()`, když +je nastavený, jinak tvar uložené hodnoty — takže pole ukazující +`9. 3. 2026 14:30` přesně tohle zpátky přijme. + +Parser je benevolentní ke všemu kromě *pořadí* částí, které určuje formát. Při +`->displayFormat('j. n. Y H:i')` skončí všechny tyhle zápisy na stejné hodnotě: + +```text +9. 3. 2026 14:30 +9.3.2026 14:30 +9/3/2026 14:30 +9. 3. 26 14:30 dvojciferný rok patří do tohoto století +9. 3. 2026 čas se nenapsal, zůstává ten, který je právě nastavený +``` + +Zápis se potvrdí při opuštění pole a klávesou Enter; Escape +ho zahodí. Cokoli, co parser nepřečte — `31. 2. 2026`, hodina nad 23, den, který +vylučuje `minDate()`/`maxDate()`/`disabledDates()` — je odmítnuto celé a vrátí se +předchozí hodnota, takže se do stavu nikdy nedostane rozečtené datum. Vyprázdnění +pole hodnotu smaže. + +Napsaná hodnota projde stejným ořezem jako vybraná: v hraniční den, který nese +čas, se hodiny stáhnou do meze místo odmítnutí — napsat `10. 3. 2026 07:00` při +`->minDate('2026-03-10 08:30')` uloží 08:30. + +Cestu přes klávesnici zavřete tam, kde hodnota opravdu musí přijít z widgetu: + +```php +DateTimePicker::make('slot')->typeable(false) +``` + +> `readOnly()` má přednost před `typeable()`: zavírá klávesnici *i* panel, +> protože hodnota není uživatelova, aby ji měnil jakoukoli cestou. +> `typeable(false)` zavírá jen klávesnici a kalendář nechává funkční. + +> Psaní je vlastnost vlastního pickeru. Klávesnice nativního inputu patří +> prohlížeči a jediný způsob, jak ji vzít, je `readonly` — což by s ní vyplo i +> vlastní picker prohlížeče — takže `typeable(false)` pod `->native()` nemá +> žádný efekt. + ## Nativní picker Výchozí je vlastní Alpine picker. Přepnutí na ovládání prohlížeče: @@ -130,8 +173,9 @@ Jedinou výjimkou je [`asMonth()`](#rezimy), který je vždy nativní. | `secondsStep(int)` | int | Krok inkrementu sekund | | `timezone(string)` | string | Zobrazí hodnotu v této timezone a při uložení ji převede zpět do timezone aplikace; jen pro `datetime` | | `native(bool $native = true)` | bool | Použít nativní ovládání prohlížeče místo vlastního pickeru (výchozí: `false`) | +| `typeable(bool\|Closure)` | bool | Umožnit hodnotu napsat, nejen vybrat (výchozí: `true`); jen vlastní picker | | `disabled(bool\|Closure)` | bool | Znepřístupnit picker | -| `readOnly(bool\|Closure)` | bool | Read-only režim | +| `readOnly(bool\|Closure)` | bool | Read-only režim — bez psaní i bez panelu | | `required()` | — | Označit jako povinné | | `live()` | — | Spustit Livewire update při změně | diff --git a/docs/cs/forms/fields/markdown-editor.md b/docs/cs/forms/fields/markdown-editor.md index 016d4637..ae4e1271 100644 --- a/docs/cs/forms/fields/markdown-editor.md +++ b/docs/cs/forms/fields/markdown-editor.md @@ -59,6 +59,24 @@ Toolbar poskytuje klávesnicí přístupná tlačítka pro: Vestavěný náhled zvládá: nadpisy (`#`, `##`, `###`), bold/italic/strikethrough, inline kód, odkazy, blockquoty a neseřazené/seřazené seznamy. Pro plné GFM vykreslení uložený Markdown post-processujte na straně serveru knihovnou jako [CommonMark](https://commonmark.thephpleague.com/). +Náhled běží v prohlížeči a zapisuje se přes `x-html`, takže syrové HTML v Markdownu se **escapuje, nevykresluje**: `` se zobrazí jako text. URL odkazů jsou navíc omezené na `http(s):`, `mailto:`, `#` a cesty od kořene — cokoli jiného se změní na `#`, takže přes náhled nelze podstrčit `javascript:` odkaz. + +## Lokalizace + +Tooltipy toolbaru i popisky záložek Psát/Náhled pocházejí ze sdílené slovní +zásoby editorů `wire-forms::fields.editor.*` — ze stejných klíčů, jaké používají +[TiptapEditor](tiptap-editor.md#lokalizace) a +[RichEditor](rich-editor.md#lokalizace), takže všechny tři editory zní v každém +jazyce stejně. Angličtina (`en`) a čeština (`cs`) jsou součástí balíčku; česká +aplikace zobrazí *Tučné*, *Kód v textu* a záložky *Psát* / *Náhled*. + +Formulaci změníte (nebo přidáte další jazyk) publikováním překladů a úpravou +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Metody | Metoda | Typ | Popis | diff --git a/docs/cs/forms/fields/rich-editor.md b/docs/cs/forms/fields/rich-editor.md index 0d2b6bde..b7d01f10 100644 --- a/docs/cs/forms/fields/rich-editor.md +++ b/docs/cs/forms/fields/rich-editor.md @@ -61,6 +61,22 @@ RichEditor::make('summary') ->maxLength(500) ``` +## Lokalizace + +Tooltipy toolbaru i prompt pro odkaz pocházejí ze sdílené slovní zásoby editorů +`wire-forms::fields.editor.*` — ze stejných klíčů, jaké používají +[TiptapEditor](tiptap-editor.md#lokalizace) a +[MarkdownEditor](markdown-editor.md#lokalizace), takže všechny tři editory zní +v každém jazyce stejně. Angličtina (`en`) a čeština (`cs`) jsou součástí balíčku; +česká aplikace zobrazí *Tučné*, *Číslovaný seznam*, *Nadpis 2* a prompt *URL odkazu*. + +Formulaci změníte (nebo přidáte další jazyk) publikováním překladů a úpravou +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Metody | Metoda | Typ | Popis | diff --git a/docs/cs/forms/fields/select.md b/docs/cs/forms/fields/select.md index c4ca47c6..0772bcd6 100644 --- a/docs/cs/forms/fields/select.md +++ b/docs/cs/forms/fields/select.md @@ -149,7 +149,6 @@ chybami; při úspěchu se nová hodnota vybere (přidá u multi-selectu). - `createOptionUsing()` vrací hodnotu nové option — skalární klíč nebo model, jehož klíč se použije. - Editace cílí na jedinou vybranou option, takže není dostupná na `multiple()`. -- `createOptionModalHeading()` / `editOptionModalHeading()` přizpůsobí nadpisy. - Funguje v samostatných `WithForms` komponentách **i** uvnitř table action modalů. - Aby nově vytvořená hodnota vykreslila label, spárujte s `getOptionLabelUsing()` nebo přednačteným seznamem options. @@ -157,6 +156,85 @@ chybami; při úspěchu se nová hodnota vybere (přidá u multi-selectu). odesílá browser události `select-option-created` / `select-option-updated`) — žádné obnovení stránky není potřeba. +### Plnohodnotný formulář, ne seznam polí + +Option schéma je běžné formulářové schéma a namountovaný option form je +plnohodnotný formulář hostitele, takže věci, které potřebují, aby hostitel našel +pole podle state path, uvnitř něj fungují stejně jako kdekoli jinde: + +```php +Select::make('category_id') + ->createOptionForm([ + Wizard::make('category')->schema([ // [tl! focus:start] + Step::make('Základ')->schema([ + TextInput::make('name')->required(), + ]), + Step::make('Zařazení')->schema([ + Select::make('parent_id') + ->getSearchResultsUsing(fn (string $search) => + Category::where('name', 'like', "%{$search}%")->pluck('name', 'id')->all() + ), + ]), + ]), // [tl! focus:end] + ]) + ->createOptionUsing(fn (array $data) => Category::create($data)->getKey()) +``` + +- [`Wizard`](../../core/schema/layout/wizard.md) gatuje jednotlivé kroky: „Next" + validuje jen pole daného kroku a při neúspěchu zůstane stát, přičemž chyby + přistanou na option bagu (`createOptionFormData.*`), kde je modal už zobrazuje. +- Vnořený `Select` dosáhne na endpoint remote searche a field actions + (`suffixAction()`, `hintAction()`, `Button`) se resolvnou a proběhnou. +- Otevření *druhého* option modalu zevnitř option formu je odmítnuto, ne vnořeno: + na každý druh je jedna mounted path a jeden data bag, takže vyhovět by znamenalo + zahodit rozepsaný formulář. + +Přidejte wizardu [`navigation(false)`](../../core/schema/layout/wizard.md#predani-navigace-jinam) +a **patička modalu převezme jeho navigaci** — Back a Next vedle Cancel, s tlačítkem +odeslání, které se objeví až na posledním kroku, místo druhého navigačního řádku +uvnitř panelu. Wizard pojmenujte, když můžou být oba option modaly otevřené naráz: +patička a wizard se najdou právě podle toho názvu. + +### Konfigurace option modalu + +Ani jeden option modal není zvláštní případ: oba se konfigurují přes stejný objekt +`Modal`, jaký používají action modaly, takže nadpis, popis, ikona, šířka, chování +při zavírání, sticky chrome i popisky obou tlačítek žijí na jednom místě. + +```php +use NyonCode\WireCore\Modals\Modal; + +Select::make('category_id') + ->options(fn () => Category::pluck('name', 'id')->all()) + ->createOptionForm([TextInput::make('name')->required()]) + ->createOptionUsing(fn (array $data) => Category::create($data)->getKey()) + ->createOptionModal(fn (Modal $modal) => $modal // [tl! focus:start] + ->heading('Nová kategorie') + ->description('Bude vybratelná, jakmile ji uložíte.') + ->icon('outline:folder-plus') + ->width('2xl') + ->closeOnClickAway(false) + ->stickyFooter() + ->submitLabel('Vytvořit kategorii') + ->cancelLabel('Zahodit')) // [tl! focus:end] + ->editOptionModal(fn (Modal $modal) => $modal->width('xl')) +``` + +Callback konfiguruje modal na místě; vrácený `Modal` ho nahradí celý. Běží při +definici schématu, ne jednou za render, takže text závislý na stavu jde přes +closure podporu samotného configu — `$modal->heading(fn (Select $field) => …)`, +vyhodnocenou s polem jako kontextem. + +`createOptionModalHeading()` / `createOptionModalWidth()` a jejich `editOption…` +dvojčata zůstávají jako zkratky a zapisují do téhož objektu, takže se dva způsoby +nastavení nadpisu nemůžou rozejít. Šířka bere case `ModalWidth` nebo jeho token +(`sm`…`7xl`, `full`); neznámý token spadne na `md` a nenakonfigurovaný modal +následuje `wire-core.modals.default_width` jako každý jiný modal. + +`id` modalu, `wire:model` a zavírací akce konfigurovatelné záměrně **nejsou**. +Klíčují teleport, podle kterého Livewire morfuje, a oba option modaly můžou být +namountované najednou — `id` nastavené volajícím by nechalo jejich obsah prohodit. + ## Reaktivita Combobox se váže deferred ve výchozím stavu. Přidejte `live()`, když na výběr reagují jiná pole @@ -253,7 +331,9 @@ Select::make('tier') | `preload()` | bool | Dychtivě naplnit remote seznam options při renderu | | `createOptionForm(array\|Closure)` / `createOptionUsing(Closure)` | — | Vytvořit novou option z modalu | | `editOptionForm(array\|Closure)` / `fillEditOptionUsing(Closure)` / `updateOptionUsing(Closure)` | — | Editovat vybranou option z modalu | -| `createOptionModalHeading(string)` / `editOptionModalHeading(string)` | string | Nadpisy modalu | +| `createOptionModal(Closure)` / `editOptionModal(Closure)` | — | Konfigurace option modalu přes kanonický objekt `Modal` | +| `createOptionModalHeading(string)` / `editOptionModalHeading(string)` | string | Nadpisy modalu (zkratka) | +| `createOptionModalWidth(string\|ModalWidth\|null)` / `editOptionModalWidth(string\|ModalWidth\|null)` | string | Šířky modalu (`sm`…`7xl`, `full`; výchozí `md`) (zkratka) | | `placeholder(string\|Closure)` | string | Label prázdné/blank option | | `disabled(bool\|Closure)` | bool | Znepřístupnit select | | `required()` | — | Označit jako povinné | diff --git a/docs/cs/forms/fields/time-picker.md b/docs/cs/forms/fields/time-picker.md index d0e485d4..84dcdb64 100644 --- a/docs/cs/forms/fields/time-picker.md +++ b/docs/cs/forms/fields/time-picker.md @@ -40,6 +40,10 @@ Samostatný setter `interval()` neexistuje — je to tentýž pojem pod názvem, už měl. `hoursStep()` a `secondsStep()` jsou zděděné, ale tady nedělají nic: seznam slotů má jeden krok, ne tři. +Interval omezuje **seznam**, ne hodnotu. Čas jde napsat rovnou do triggeru, takže +`08:07` zůstává dosažitelný i při třicetiminutovém kroku — napsaný čas odmítnou +jen meze. Viz [Psaní z klávesnice](date-time-picker.md#psani-z-klavesnice). + ## Meze `minDate()` / `maxDate()` se čtou jako časy a sloty mimo ně **zakážou**, takže @@ -64,6 +68,7 @@ Hodnotová strana je celá zděděná, takže tohle se chová přesně podle dok TimePicker::make('opens_at') ->withSeconds() // ukládá H:i:s; sloty pořád padají na :00 ->displayFormat('H:i') + ->typeable(false) // jen seznam — do triggeru se psát nedá ->native() // nativní prohlížeče ->placeholder('Vyber čas') ``` diff --git a/docs/cs/forms/fields/tiptap-editor.md b/docs/cs/forms/fields/tiptap-editor.md index a9879bcf..8f8dac68 100644 --- a/docs/cs/forms/fields/tiptap-editor.md +++ b/docs/cs/forms/fields/tiptap-editor.md @@ -22,12 +22,16 @@ zapnutí tabulek nikdy neposílá druhou kopii jádra editoru. Script tagy `@assets`; registrují Alpine komponentu `tiptapEditor`, na kterou pohled spoléhá (Alpine se dodává s Livewire). -> **Publikování assetu (volitelné).** Pokud dáváte přednost servírování souborů přes -> vlastní asset pipeline/CDN, publikujte je pomocí: +> **Publikování assetu (volitelné).** Pokud má soubory servírovat váš webserver +> místo routy balíčku, publikujte je pomocí: > ```bash -> php artisan vendor:publish --tag=wire-forms::assets +> php artisan vendor:publish --tag=laravel-assets --force > ``` -> To zkopíruje bundly do `public/vendor/wire-forms/`. +> To zkopíruje bundly do `public/vendor/wire-forms/` — celého stacku, nejen tohoto +> balíčku — a editor od té chvíle emituje tyhle cesty včetně cache-busteru. Publish +> zrcadlí `dist/` doslova, takže si entry pointy dál resolvují sdílený chunk relativně +> vůči `vendor/wire-forms/tiptap/`. Viz +> [Začínáme → JavaScriptové assety](../../getting-started.md#javascriptove-assety). > **Přispěvatelé.** Bundly se generují z > `packages/forms/resources/js/tiptap-editor.js` a `tiptap-editor-addons.js` a @@ -45,6 +49,34 @@ zapnutí tabulek nikdy neposílá druhou kopii jádra editoru. Script tagy TiptapEditor::make('content') ``` +## Výchozí obsah + +Editor se otevře nad hodnotou z `->default()` — kanonického výchozího nastavení, +které má každá komponenta; žádná metoda navíc jen pro editor. Je to **markup, ne +holý text**, takže šablona přichází předformátovaná: + +```php +TiptapEditor::make('minutes') + ->default('

Zápis z porady

Nějaký text.

  • První bod
') +``` + +Jak se to vyhodnotí, v tomto pořadí: + +1. **Runtime formuláře hodnotu naseeduje.** `fill()` (a stejně tak výchozí stav + modalové akce) zapíše `->default()` do state bagu pro každý klíč, který volající + nedodal, takže editor se prostě otevře nad hodnotou, která už tam je. +2. **Editor ji naseeduje, když to hostitel neudělal** — `null` sloupec, ručně + navázaná property — výchozí obsah dosadí vždy, když je navázaná hodnota + prázdná, a rozparsovaný dokument pošle zpět do Livewire, takže uložení + formuláře, kterého se uživatel ani nedotkl, uloží šablonu, a ne nic. +3. **Vyprázdněný editor není prázdný.** Smazání obsahu uloží `

`, takže + znovuotevření dokumentu, který uživatel záměrně vyčistil, výchozí obsah + *nevrátí*. U editačního formuláře, kde je sloupec skutečně `null`, přidejte + `->defaultOnNull()`, aby default doplnil hodnotu i na straně serveru. + +Při `->outputJson()` může být výchozí hodnotou TipTap JSON dokument jako řetězec, +nebo totéž HTML — HTML se tak jako tak rozparsuje na dokument a uloží jako JSON. + ## Vlastní toolbar ```php @@ -122,6 +154,32 @@ TiptapEditor::make('content') ->disabled(fn () => ! $this->canEdit) ``` +## Lokalizace + +Editor si nenese vlastní angličtinu. Tooltipy toolbaru, popisky nadpisů i +prohlížečové prompty, které otevírá tlačítko odkazu a obrázku, se všechny +překládají z `wire-forms::fields.editor.*`, takže pole respektuje +`app()->getLocale()`. Angličtina (`en`) a čeština (`cs`) jsou součástí balíčku — +česká aplikace zobrazí *Tučné*, *Odrážkový seznam*, *Nadpis 2* a prompt +*URL odkazu*. + +Titulky promptů se vyhodnocují v PHP a předávají se do Alpine konfigurace +editoru — proto se změna jazyka propíše i do řetězců, které žijí uvnitř JS bundlu. + +[RichEditor](rich-editor.md#lokalizace) a +[MarkdownEditor](markdown-editor.md#lokalizace) popisují své toolbary z týchž +klíčů, takže všechny tři editory zní v každém jazyce stejně. + +Formulaci změníte (nebo přidáte další jazyk) publikováním překladů a úpravou +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + +Popisky tlačítek zůstávají `H1` / `H2` / `H3` ve všech jazycích — to jsou +symboly, ne slova; překládá se tooltip. + ## Dostupná toolbarová tlačítka | Klíč | Popis | @@ -167,6 +225,8 @@ TiptapEditor::make('content') | `toolbarButtons(array)` | array | Přepsat seznam toolbarových tlačítek | | `disableToolbarButtons(array)` | array | Odstranit konkrétní tlačítka | | `disableAllToolbarButtons()` | — | Skrýt toolbar úplně | +| `default(string\|Closure)` | string | Předformátovaný dokument, nad kterým se prázdný editor otevře | +| `defaultOnNull()` | — | Nechat `default()` doplnit i existující `null` při fill | | `outputHtml()` | — | Uložit obsah jako HTML (výchozí) | | `outputJson()` | — | Uložit obsah jako TipTap JSON řetězec | | `withImages(bool)` | bool | Zapnout rozšíření obrázků + tlačítko | diff --git a/docs/cs/getting-started.md b/docs/cs/getting-started.md index 617a01fe..7c4fa0b5 100644 --- a/docs/cs/getting-started.md +++ b/docs/cs/getting-started.md @@ -11,10 +11,20 @@ Tento průvodce popisuje produkční nastavení Wire v Laravel aplikaci. | Závislost | Verze | |------------|---------| | PHP | ^8.2 | -| Laravel | 10, 11 nebo 12 | +| Laravel | 12.61+ nebo 13.12+ | | Livewire | 3.x | | Tailwind CSS | 3.x+ | | Alpine.js | 3.x+ (součástí Livewire) | +| `nyoncode/laravel-package-toolkit` | ^2.4 (nainstaluje se sám) | + +Poslední řádek si sami nevyžadujete — Composer ho stáhne spolu s balíčky Wire. +Je tu proto, že určuje dva řádky nad sebou. Toolkit vlastní mirror do +`public/vendor`, který dostane JavaScriptové bundly na disk (viz +[JavaScriptové assety](#javascriptove-assety)), a verze 2.4 vyžaduje +`illuminate/support ^12.61.1|^13.12.0` — takže právě tohle, a ne `^12.0` +deklarované balíčky Wire, je verze Laravelu, proti které se instalace opravdu +řeší. Aplikace, která si toolkit připíná sama, mu musí povolit `^2.4`, jinak +`composer require nyoncode/wire-table` vůbec neprojde. ## Instalace @@ -169,8 +179,8 @@ Interaktivní části Wire — dropdowny, kontextové menu řádku, taby, wizard buňky inline editace, fill handle, výběr řádků, record akce, drag & drop řazení — jsou malé Alpine komponenty dodávané jako předsestavené bundly přímo z balíčků. Není co instalovat, není co publikovat a na vaší straně není žádný -build krok: každý balíček servíruje své bundly z vlastní routy, s cache-bustingem -podle času poslední změny souboru. +build krok: balíčky si své bundly samy zkopírují do `public/vendor/` a servírují je +jako statické soubory, s cache-bustingem podle času poslední změny souboru. **`@wireStackScripts` dostane bundly všech nainstalovaných balíčků do dokumentu.** Jeden řádek v `` layoutu a každý controller je přítomný na každé stránce: @@ -216,6 +226,59 @@ jediné umístění, které cesta cachovaného zpět/vpřed nepředběhne. > vyzvedne, až se vykreslí. Grafy navíc potřebují Chart.js, který zůstává vlastní > závislostí vaší aplikace. +### Odkud se ty soubory vlastně berou + +Jsou to **reálné soubory pod `public/vendor/`** a dostanou se tam samy. +První vykreslení stránky po nasazení zkopíruje bundly každého balíčku z instalace +do `public/` a emituje tyhle cesty: + +```html + +``` + +Nic se nespouští, nic nenastavuje. Kopírování je inkrementální — soubor, který už +je na místě a je aktuální, se nechá být — takže v ustáleném stavu request udělá +hrst `stat` volání a nula zápisů. Po upgradu je to jedna kopie na změněný bundle, +na jednom requestu. Kopie přistávají přes dočasný soubor a atomický přesun, takže +prohlížeč stahující bundle uprostřed kopírování nikdy nedostane půlku. + +Záleží na tom víc, než to zní. Servírování bundlů z **routy** balíčku funguje jen +tehdy, když se request dostane do PHP — a hodně rozšířené nastavení webserveru +odpovídá na `.js` samo: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # routa není soubor na disku → 404 +} +``` + +Na sdíleném hostingu tenhle blok často není váš, abyste ho měnili — a úplně stejně +rozbíjí i Livewire vlastní `/livewire/livewire.js`. Soubor, který existuje, +naservíruje každá konfigurace webserveru, jaká je, a proto vám ho balíčky připraví. + +**Publikování je pořád podporované** a dělá tutéž kopii dopředu, čímž ji sundá +z prvního requestu po nasazení: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +`laravel-assets` je tag, který skeleton Laravelu už spouští ze svého composer +`post-update-cmd`, takže `composer update` udržuje kopie aktuální sám od sebe. Ani +příkaz, ani ten hook nejsou povinné. + +### Když `public/` není zapisovatelné + +Read-only kontejner, Vapor, zpevněné nasazení: nic nespadne. Bundly servíruje routa +přesně jako předtím, a chcete buď publikovací příkaz výše (spuštěný při buildu, kdy +je filesystém ještě zapisovatelný), nebo `try_files … /index.php?$query_string`, aby +byla routa dosažitelná. + +Pokud tam už **starší** kopie je, servíruje se dál, místo aby se spadlo na routu, +která nemusí být dosažitelná — a konzole to řekne, na každé stránce a bez ohledu na +`APP_DEBUG`, včetně názvů bundlů a příkazu, který to spraví. Viz +[Řešení potíží](troubleshooting.md#javascriptove-404-a-wirex-is-not-defined). + ## Publikování konfigurace (volitelné) ```bash diff --git a/docs/cs/sortable/api-reference.md b/docs/cs/sortable/api-reference.md index a531ff4a..59beec83 100644 --- a/docs/cs/sortable/api-reference.md +++ b/docs/cs/sortable/api-reference.md @@ -105,9 +105,12 @@ Zpracovat drag & drop řádků. Voláno Alpine.js po dokončení drag operace. A Každá položka: `['value' => string|int, 'order' => int]` +`order` je nová pozice řádku na obrazovce, nikoli zapisovaná hodnota. Tažené řádky si ponechají sadu hodnot pořadí, které už měly, rozdanou v novém vizuálním pořadí -- tažení nad prohledanou, vyfiltrovanou nebo stránkovanou podmnožinou tedy nemůže pohnout řádky, které nezobrazuje; viz [Přeřazování zúženého seznamu](row-sorting.md#prerazovani-zuzeneho-seznamu). Pozice se zapíšou doslova jen tehdy, když je sloupec pořadí prázdný nebo konstantní a nemá co rozdávat. + No-op, pokud: - Tabulka není reorderable - Tabulka není v reorder režimu (`$isReordering === false`) +- Klíč řádku leží mimo základní dotaz tabulky (ze zápisu vypadne) #### `reorderColumns(array $columnOrder): void` @@ -140,7 +143,7 @@ Vrací `'wire-sortable::tables.index'`, když je zapnuté řazení řádků nebo #### `interceptTableRecords(): LengthAwarePaginator|Paginator|CursorPaginator|Collection|null` -V reorder režimu (bez `paginatedWhileReordering`): obejde hledání, filtry, řazení a stránkování. Vrátí všechny záznamy seřazené podle sort sloupce vzestupně. +V reorder režimu (bez `paginatedWhileReordering`): obejde stránkování a řazení podle sloupce, ale hledání a filtry ponechá v platnosti. Vrátí všechny odpovídající záznamy seřazené podle sort sloupce vzestupně. Jinak: vrátí `null`, aby `WithTable` zpracoval načítání záznamů normálně. diff --git a/docs/cs/sortable/row-sorting.md b/docs/cs/sortable/row-sorting.md index aeee4f88..bbffaf43 100644 --- a/docs/cs/sortable/row-sorting.md +++ b/docs/cs/sortable/row-sorting.md @@ -56,12 +56,18 @@ Blade šablona používá computed vlastnost `$table`: 3. V reorder režimu: - Na každém řádku se objeví drag handly - Stránkování je vypnuté (zobrazí se všechny záznamy) - - Řazení, hledání a filtry se obejdou - - Řádky jsou seřazené podle sort sloupce vzestupně + - Řazení podle sloupce se obejde -- řádky jsou seřazené podle sort sloupce vzestupně + - **Hledání a filtry zůstávají v platnosti**, seznam tedy jde stále zúžit 4. Uživatel táhne řádky na požadovanou pozici 5. Při konci tažení se nové pořadí uloží do databáze 6. Uživatel klikne na **„Done reordering"** pro opuštění reorder režimu -7. Tabulka se vrátí do normálního stavu s obnoveným stránkováním, řazením a filtry +7. Tabulka se vrátí do normálního stavu s obnoveným stránkováním a řazením podle sloupce + +Řazení podle sloupce musí ustoupit, protože pořadí na obrazovce je přesně to +pořadí, které se při puštění zapíše zpět: může být jedině pořadím sort sloupce. +Hledání a filtry ustupovat nemusí, protože mění to, *které* řádky lze táhnout, +nikoli význam tažení -- proč je to bezpečné, viz +[Přeřazování zúženého seznamu](#prerazovani-zuzeneho-seznamu). ## Vlastní sloupec pořadí @@ -94,7 +100,7 @@ return $table ->columns([...]); ``` -V tomto režimu je tabulka vždy v reorder režimu -- žádné toggle tlačítko se nevykreslí a `$isReordering` je při mountu nastaveno na `true`. +V tomto režimu je tabulka vždy v reorder režimu -- žádné toggle tlačítko se nevykreslí a `$isReordering` je při mountu nastaveno na `true`. Cesta zpět k běžné tabulce neexistuje, a přesně proto reorder režim ponechává hledání a filtry funkční: vyhledávací pole by na vždy přeřaditelné tabulce jinak nikdy nic neudělalo. ## Podmíněné řazení @@ -121,7 +127,37 @@ return $table ->columns([...]); ``` -> **Poznámka:** Se zapnutým stránkováním mohou uživatelé přeřazovat jen v rámci aktuální stránky. +> **Poznámka:** Se zapnutým stránkováním mohou uživatelé přeřazovat jen v rámci aktuální stránky. Puštění přeuspořádá řádky té stránky mezi sebou a všechny ostatní stránky nechá tam, kde byly. + +## Přeřazování zúženého seznamu + +Uživatel v reorder režimu může stále hledat, filtrovat a -- s +`paginatedWhileReordering()` -- stránkovat. Tažení se tedy obvykle odehrává nad +*podmnožinou* tabulky a řádky, které tato podmnožina skryla, se pohnout nesmí. + +A nepohnou se, protože puštění řádky, které dostane, nepřečísluje. Posbírá +hodnoty pořadí, které tyto řádky už mají, seřadí je vzestupně a rozdá je zpět +v novém vizuálním pořadí: + +```php +// Řádky s sort_order 10, 20, 30. Přetáhněte poslední úplně nahoru: +// před po +// A 10 C 10 +// B 20 A 20 +// C 30 B 30 +``` + +Tři důsledky, které stojí za to znát: + +- **Řádky mimo tažení se nikdy nepohnou.** Zůstávají na svých pozicích, takže + hledání `audit` může přeřadit čtyři odpovídající řádky, aniž by rozhodilo čtyři + sta, které skrylo. +- **Mezery zůstávají zachované.** Sloupec pořadí `10, 20, 30` zůstane + `10, 20, 30`. Pokud necháváte mezery pro pozdější vkládání, přeřazení je + nezavře. +- **Prázdný nebo konstantní sloupec pořadí nemá co rozdávat.** Tam, a jen tam, se + místo toho zapíšou pozice od klienta (`1..n`) -- což je pro sloupec, který + žádné pořadí nenesl, ta správná odpověď. ## Lifecycle hooky diff --git a/docs/cs/table/advanced.md b/docs/cs/table/advanced.md index 8737eb54..015a8a02 100644 --- a/docs/cs/table/advanced.md +++ b/docs/cs/table/advanced.md @@ -731,6 +731,78 @@ akcí ji stále zobrazí přímo. Menu přebírá nastavení tabulky `sheetOnMob `mobileBreakpoint()` (na malých obrazovkách se ve výchozím stavu chová jako spodní sheet). +### Akce hlavičky na telefonu + +Toolbar má stejný problém s teteskem o patro výš: už v něm sedí vyhledávací pole, +spouštěč filtrů a menu zobrazení, a dvě popsaná tlačítka hlavičky („Nová +faktura", „Import CSV") celý řádek na šířce telefonu zalomí. +`collapseHeaderActionsOnMobile()` je sbalí do jednoho menu: + +```php +$table->collapseHeaderActionsOnMobile() // jeden spouštěč "⋮" místo tlačítek +``` + +Na rozdíl od `collapseActionsOnMobile()` k tomu není potřeba `stackedOnMobile()` +— toolbar je na každé šířce tentýž, takže sbalení je čistě přepínač podle šířky. +Přepíná se na **`mobileBreakpoint()`** tabulky (výchozí `sm`, tedy pod 640 px), +ne na breakpointu skládání: + +```php +$table + ->mobileBreakpoint('md') // sbalit až pod 768 px + ->collapseHeaderActionsOnMobile() +``` + +Sbalí se od **2** spustitelných akcí hlavičky výš — jedno tlačítko ještě není +tlačenice a toolbar se sbaluje dřív než akce řádku v kartě, protože sdílí řádek +s vyhledávacím polem. Práh nastavíš stejně: + +```php +->collapseHeaderActionsOnMobile(threshold: 3) // dvě tlačítka nechat vedle sebe, sbalit od tří +->collapseHeaderActionsOnMobile(threshold: 1) // sbalit vždy +``` + +Počítají se jen akce, které smí uživatel spustit, takže tabulka, jejíž druhá akce +je zahrazená autorizací, si první nechá jako běžné tlačítko. Menu je kanonická +`ActionGroup` — přebírá `sheetOnMobile()` / `mobileBreakpoint()`, takže se na +telefonu ve výchozím stavu otevře jako spodní sheet, a když jeho podmínky přežije +jediná akce, sbalí se rovnou na její tlačítko. + +Obě poloviny leží v dokumentu na každé šířce (co je vidět, rozhoduje CSS), takže +se sbalená kopie vykreslí **bez** `keyboardShortcut()` jednotlivých akcí: +vykreslená zkratka je posluchač na *okně* a druhá registrace by na jeden stisk +spustila akci dvakrát. Viditelné desktopové tlačítko si ji ponechá. + +```php +class ListInvoices extends Component +{ + use WithTable; + + public function table(Table $table): Table + { + return $table + ->model(Invoice::class) + ->columns([ + TextColumn::make('number'), + TextColumn::make('total')->money('CZK'), + ]) + ->headerActions([ + HeaderAction::make('create') // [tl! focus:start] + ->label('Nová faktura') + ->icon('plus') + ->keyboardShortcut('c') // jen desktop — viz výše + ->url(route('invoices.create')), + + HeaderAction::make('import') + ->label('Import CSV') + ->icon('arrow-up-tray') + ->action(fn () => $this->importInvoices()), + ]) + ->collapseHeaderActionsOnMobile(); // [tl! focus:end] + } +} +``` + ### Anatomie karty Karta je záznam, ne přestrojené pořadí sloupců. Hierarchii nesou čtyři pojmenované diff --git a/docs/cs/table/columns/index.md b/docs/cs/table/columns/index.md index 72699337..3bf09ab8 100644 --- a/docs/cs/table/columns/index.md +++ b/docs/cs/table/columns/index.md @@ -120,6 +120,11 @@ Sloupec ponechaný jako text porovnání přeskočí, místo aby porovnával lexikograficky — chybná nebo chybějící deklarace tak jen zúží, čemu hledání rozumí, nikdy nevrátí špatné řádky. +Samotná deklarace nic nezapíná. Hledatelný sloupec, který typ deklaruje, zatímco +hledání tabulky rozsahy nečte, se při renderu tabulky odmítne a pojmenuje +chybějící volání — jinak by se tabulka vrátila prázdná, protože `10..20` by se +hledalo jako doslovný text. + `'code'` je jediný typ, který se **nikdy** neodvozuje: říká, že hodnota je řada plus číslo **doplněné nulami** (`8866 01`, `8866 02`), což je právě to, co dělá porovnání textem správným — a ví to jen vlastník. Odemyká diff --git a/docs/cs/table/overview.md b/docs/cs/table/overview.md index 6fc7b757..d1ee8e63 100644 --- a/docs/cs/table/overview.md +++ b/docs/cs/table/overview.md @@ -329,12 +329,24 @@ nulami. Označte sloupec přes zadávat rozsah: ```php -TextColumn::make('reference')->searchable()->searchAs('code'); +$table + ->searchable() + ->search(fn (SearchConfig $s) => $s->tokenize()->ranges()) + ->columns([ + TextColumn::make('reference')->searchable()->searchAs('code'), + ]); // Uživatel napíše: 8866 01..08 // SQL: reference BETWEEN '8866 01' AND '8866 08' ``` +Potřeba jsou obě poloviny: `searchAs('code')` říká, co sloupec drží, `ranges()` +je to, co vůbec dovolí rozsah napsat, a `tokenize()` je to, co oddělí řadu od +pořadového čísla. Deklarace, na kterou se hledání nemůže zeptat, se při renderu +tabulky odmítne — s vypnutým `ranges()` by se `8866 01..08` hledalo jako +doslovný text a tabulka by se vrátila prázdná, aniž by to na obrazovce cokoli +vysvětlovalo. + Mezera uvnitř kódu je zároveň tím, co výraz dělí — `8866 01..08` tedy přijde jako slovo `8866` a rozsah `01..08`. Rozsah si nese slovo, které mu přímo předchází, a sloupec typu kód jím doplní obě meze — řadu tedy píšete jednou, ne @@ -346,10 +358,12 @@ Dvě pravidla, která to drží poctivé: - **Číslo musí být uložené doplněné nulami a psát se tak, jak je uložené.** Porovnání textem je správně jen dokud je šířka konstantní (`01 … 08` se - abecedně řadí stejně jako číselně, `9 … 10` už ne). Napsat `1..8` proti - uloženým `01 … 08` nenajde nic. Rozsah přes hranici šířky se doplní za vás — - `8866 50..100` se čte jako `050..100`, protože stý člen může existovat jen - v třímístné řadě. + abecedně řadí stejně jako číselně, `9 … 10` už ne). Rozsah se porovnává v té + šířce, v jaké byl napsaný — `1..8` proti uloženým `01 … 08` je tedy + `BETWEEN '8866 1' AND '8866 8'` a porovnává se textem: celou doplněnou řadu + mine a dosáhne místo toho na `8866 12`. Rozsah přes hranici šířky se doplní za + vás — `8866 50..100` se čte jako `050..100`, protože stý člen může existovat + jen v třímístné řadě. - **Řada je jedno slovo před rozsahem.** `faktura 8866 01..08` hledá rozsah uvnitř `8866` a `faktura` musí sedet zvlášť; kód se dvěma mezerami je mimo dosah. @@ -528,6 +542,9 @@ kombinovat na téže tabulce: // Sbalit akce řádku v mobilní kartě do jednoho rozbalovacího menu (od N akcí) ->collapseActionsOnMobile(bool $collapse = true, int $threshold = 3) + +// Totéž pro akce hlavičky v toolbaru, pod mobileBreakpoint() tabulky +->collapseHeaderActionsOnMobile(bool $collapse = true, int $threshold = 2) ``` ### Prázdný stav diff --git a/docs/cs/troubleshooting.md b/docs/cs/troubleshooting.md index 832a07f0..d55b323d 100644 --- a/docs/cs/troubleshooting.md +++ b/docs/cs/troubleshooting.md @@ -134,6 +134,60 @@ Viz [Začínáme → JavaScriptové assety](getting-started.md#javascriptove-ass --- +## JavaScriptové 404 a `wireX is not defined` + +**Příznak:** Tentýž `ReferenceError` jako v předchozí sekci, ale na *každé* stránce +a bez ohledu na to, jak jste se na ni dostali — objeví se i po tvrdém reloadu. +V network tabu jsou 404 na +`/wire-core/assets/dropdown.js`, `/wire-table/assets/records.js` nebo sourozence +pod `/wire-forms/…` či `/wire-sortable/…`. + +**Příčina:** Sešly se dvě věci. Balíčky si normálně bundly zkopírují do +`public/vendor/` a emitují *tyhle* cesty, takže PHP nic neřeší — URL +`/wire-core/assets/…` ve vašem markupu znamená, že se kopie nepovedla a zaskakuje +za ni routa balíčku. A váš webserver na tu routu odpovídá sám, místo aby ji předal +PHP. Standardní nginx konfigurace Laravelu posílá cokoliv, co není na disku, do +`index.php`, konfigurace s blokem pro statické assety už ne: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # routa není soubor na disku → 404, PHP to nikdy neuvidí +} +``` + +Nic z toho není specifické pro tyhle balíčky: tentýž blok vrací 404 i na Livewire +vlastní `/livewire/livewire.js`. + +**Řešení — zapisovatelné `public/`, nebo kopie při buildu.** Obvyklou příčinou je +`public/`, do kterého webový uživatel nesmí zapisovat, nebo read-only kontejner. +Buď zápis povolte, nebo kopii udělejte, dokud je filesystém ještě zapisovatelný: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +**Nebo zpřístupněte routu** tím, že necháte blok propadnout do front controlleru — +správná odpověď tam, kde zapisovatelné `public/` opravdu není ve hře: + +```nginx +location ~* \.(js|css)$ { + try_files $uri /index.php?$query_string; // [tl! focus] +} +``` + +Příbuzné varování, když kopie existují, ale po upgradu je nešlo obnovit: + +```text +wireStack: the published copies of wire-core/dropdown are older than the bundles +the packages ship, and are what this page just loaded. +``` + +Stránka funguje dál — starý bundle je lepší než žádný — ale stojí za tím tentýž +problém se zápisem. Viz +[Začínáme → JavaScriptové assety](getting-started.md#javascriptove-assety). + +--- + ## Řazení přestalo fungovat, nebo můj kód přišel o `window.Sortable` diff --git a/docs/cs/upgrade.md b/docs/cs/upgrade.md index a8a54144..d6269851 100644 --- a/docs/cs/upgrade.md +++ b/docs/cs/upgrade.md @@ -35,14 +35,41 @@ zvýšením si přečtěte changelog: | Závislost | Podporováno | |------------|-----------| | PHP | 8.2, 8.3, 8.4 | -| Laravel | 10, 11, 12 | +| Laravel | 12.61+, 13.12+ | | Livewire | 3.x | | Tailwind CSS | 3.x nebo 4.x | +| `nyoncode/laravel-package-toolkit` | ^2.4 | Před upgradem ověřte, že je vaše aplikace splňuje. --- +## Minimální verze závislostí (1.17) + +**Laravel 10 a 11 končí.** Verze 1.17 přesunula JavaScriptové bundly z package +route do reálných souborů pod `public/vendor` a kód, který je tam zrcadlí, žije +v `nyoncode/laravel-package-toolkit` — vedle deklarace `hasAssets()` a publish +tagu, jehož je čtecí stranou. Toolkit stojí na +`illuminate/support ^12.61.1|^13.12.0` a minimum závislosti je i vaše minimum: +aplikace pod ním balíčky Wire nenainstaluje, ať v jejich vlastním +`composer.json` stojí `^12.0`. Nejdřív povyšte Laravel, pak Wire. + +**Constraint toolkitu je `^2.4`.** Přímo si ho nevyžadujete, takže v běžném +případě ho `composer update "nyoncode/wire-*"` posune se vším ostatním a není co +řešit. Viditelný je jen ve dvou situacích: + +- váš `composer.json` `nyoncode/laravel-package-toolkit` jmenuje — protože na něm + stavíte vlastní balíček, nebo ze starého pinu — a drží ho pod 2.4. Composer pak + hlásí jako neinstalovatelné balíčky Wire, ne toolkit jako starý, takže ten + constraint rozšiřte na `^2.4` jako první. +- běžíte na Octane. Memo assetů, které je jinak per-request a tady přežívá celý + worker, se na `RequestTerminated` zahazuje přes `PublishedAssets::flush()` + z toolkitu, a 2.4 je první vydání, které ho nese. Pod ním worker, který přežije + deploy, dál emituje `?id=` z minulého vydání a `wire:navigate` si nových + bundlů nikdy nevšimne. + +--- + ## Kroky upgradu 1. **Přečtěte si changelog.** Zkontrolujte `CHANGELOG.md` pro verze, které diff --git a/docs/forms/custom-fields.md b/docs/forms/custom-fields.md index 3e616b2d..e9e2539b 100644 --- a/docs/forms/custom-fields.md +++ b/docs/forms/custom-fields.md @@ -732,25 +732,30 @@ emitted twice on one page (a per-surface include plus [`@wireStackScripts`](../getting-started.md#javascript-assets)), and the browser will execute it both times. -If your package ships more than an occasional heavy field, declare the bundle -with the shared `AssetManager` from your own service provider's boot instead of -only including it per surface, and `@wireStackScripts` will emit it alongside -Wire's own: +If your package ships more than an occasional heavy field, declare the bundle in +your own package's `configure()` instead of only including it per surface, and +`@wireStackScripts` will emit it alongside Wire's own: ```php -use NyonCode\WireCore\Foundation\Assets\AssetManager; -use NyonCode\WireCore\Foundation\Assets\Js; +use NyonCode\WireCore\Foundation\Assets\Bundle; -app(AssetManager::class)->register([ - Js::make('my-field', __DIR__.'/../dist/my-field.js')->navigateTrack(), -], 'my-package'); +$packager + ->hasAssets('dist', entries: [ + Bundle::make('my-field.js'), + ]) + ->hasAssetFallback(Bundle::servedByRoute('my-package')); ``` -`Js::make()` takes the bundle id and a **filesystem** path (that is where the -`?id=` cache-buster comes from) and resolves its URL from your package's -`{package}.asset` named route. Keep heavy bodies off pages that do not need them -with `->loadedOnRequest()` — but never the small controller that registers the -component. +Entries are keyed by the **shipped filename**, relative to the asset directory. +`Bundle::make()` declares what every Wire bundle is — a classic (non-module) +script, because an ES module's top-level declarations never reach `window` and +your registration would silently do nothing. `hasAssetFallback()` keeps the tag +alive where `public/` cannot be written, by pointing at your package's own +`{package}.asset` route. + +Keep heavy bodies off pages that do not need them by leaving them out of +`entries:` and having the field deliver them per surface — but never the small +controller that registers the component. --- diff --git a/docs/forms/fields/date-time-picker.md b/docs/forms/fields/date-time-picker.md index 9bfffdae..3d56b49e 100644 --- a/docs/forms/fields/date-time-picker.md +++ b/docs/forms/fields/date-time-picker.md @@ -96,6 +96,50 @@ DateTimePicker::make('date') > the stored value is untouched. It is honoured by the custom picker; a native > input's display format belongs to the browser and the user's locale. +## Typing + +The trigger is a text box, not a button: the value can be typed as well as +picked. What is typed is read back through the same format the box shows — +`displayFormat()` when there is one, the stored shape otherwise — so a field +displaying `9. 3. 2026 14:30` accepts exactly that back. + +The parser is loose about everything except the *order* of the parts, which the +format fixes. Under `->displayFormat('j. n. Y H:i')` all of these land on the +same value: + +```text +9. 3. 2026 14:30 +9.3.2026 14:30 +9/3/2026 14:30 +9. 3. 26 14:30 a two-digit year is this century +9. 3. 2026 no clock typed, so the time already showing is kept +``` + +The entry commits on blur and on Enter; Escape abandons it. +Anything the parser cannot read — `31. 2. 2026`, an hour past 23, a day that +`minDate()`/`maxDate()`/`disabledDates()` exclude — is refused whole and the +previous value comes back, so a half-read date can never reach the state. +Emptying the box clears the field. + +A typed value goes through the same clamp a picked one does: on a boundary day +that carries a time, the clock is pulled inside the bound rather than rejected — +typing `10. 3. 2026 07:00` under `->minDate('2026-03-10 08:30')` stores 08:30. + +Close the keyboard route where the value really must come from the widget: + +```php +DateTimePicker::make('slot')->typeable(false) +``` + +> `readOnly()` outranks `typeable()`: it closes the keyboard *and* the panel, +> because the value is not the user's to change by any route. `typeable(false)` +> closes only the keyboard and leaves the calendar working. + +> Typing is a custom-picker feature. A native input's keyboard belongs to the +> browser, and the only way to take it away is `readonly` — which would disable +> the browser's own picker along with it — so `typeable(false)` has no effect +> under `->native()`. + ## Native Picker The custom Alpine picker is the default. Opt out to the browser's own control: @@ -130,8 +174,9 @@ The only exception is [`asMonth()`](#modes), which is always native. | `secondsStep(int)` | int | Second increment step | | `timezone(string)` | string | Show the value in this timezone and convert back to the app timezone on save; `datetime` only | | `native(bool $native = true)` | bool | Use the browser-native control instead of the custom picker (default: `false`) | +| `typeable(bool\|Closure)` | bool | Let the value be typed into the input as well as picked (default: `true`); custom picker only | | `disabled(bool\|Closure)` | bool | Disable the picker | -| `readOnly(bool\|Closure)` | bool | Read-only mode | +| `readOnly(bool\|Closure)` | bool | Read-only mode — no typing and no panel | | `required()` | — | Mark as required | | `live()` | — | Trigger Livewire update on change | diff --git a/docs/forms/fields/markdown-editor.md b/docs/forms/fields/markdown-editor.md index 7f9cd623..6fce3a2c 100644 --- a/docs/forms/fields/markdown-editor.md +++ b/docs/forms/fields/markdown-editor.md @@ -59,6 +59,24 @@ The toolbar provides keyboard-accessible buttons for: The built-in preview handles: headings (`#`, `##`, `###`), bold/italic/strikethrough, inline code, links, blockquotes, and unordered/ordered lists. For full GFM rendering, post-process the stored Markdown on the server side using a library like [CommonMark](https://commonmark.thephpleague.com/). +The preview runs in the browser and writes through `x-html`, so raw HTML in the Markdown is **escaped, not rendered**: `` shows as text. Link URLs are additionally restricted to `http(s):`, `mailto:`, `#` and root-relative paths — anything else becomes `#`, so a `javascript:` link cannot be planted through the preview. + +## Localization + +Toolbar tooltips and the Write/Preview tab labels come from the shared editor +vocabulary `wire-forms::fields.editor.*` — the same keys +[TiptapEditor](tiptap-editor.md#localization) and +[RichEditor](rich-editor.md#localization) use, so the three editors read alike in +every locale. English (`en`) and Czech (`cs`) ship with the package; a Czech app +shows *Tučné*, *Kód v textu*, and the tabs *Psát* / *Náhled*. + +Reword a string, or add a locale, by publishing the translations and editing +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Methods | Method | Type | Description | diff --git a/docs/forms/fields/rich-editor.md b/docs/forms/fields/rich-editor.md index 47e19810..f15df440 100644 --- a/docs/forms/fields/rich-editor.md +++ b/docs/forms/fields/rich-editor.md @@ -61,6 +61,22 @@ RichEditor::make('summary') ->maxLength(500) ``` +## Localization + +Toolbar tooltips and the link prompt come from the shared editor vocabulary +`wire-forms::fields.editor.*` — the same keys +[TiptapEditor](tiptap-editor.md#localization) and +[MarkdownEditor](markdown-editor.md#localization) use, so the three editors read +alike in every locale. English (`en`) and Czech (`cs`) ship with the package; a +Czech app shows *Tučné*, *Číslovaný seznam*, *Nadpis 2*, and prompts *URL odkazu*. + +Reword a string, or add a locale, by publishing the translations and editing +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Methods | Method | Type | Description | diff --git a/docs/forms/fields/select.md b/docs/forms/fields/select.md index 4a83a553..2b3e59ce 100644 --- a/docs/forms/fields/select.md +++ b/docs/forms/fields/select.md @@ -146,7 +146,6 @@ errors; on success the new value is selected (appended for a multi-select). - `createOptionUsing()` returns the new option's value — a scalar key, or a model whose key is used. - Editing targets the single selected option, so it is unavailable on `multiple()`. -- `createOptionModalHeading()` / `editOptionModalHeading()` customise the headings. - Works in standalone `WithForms` components **and** inside table action modals. - So the newly created value renders a label, pair with `getOptionLabelUsing()` or a preloaded option list. @@ -154,6 +153,85 @@ errors; on success the new value is selected (appended for a multi-select). dispatches `select-option-created` / `select-option-updated` browser events) — no page refresh needed. +### A Full Form, Not A Field List + +The option schema is an ordinary form schema and the mounted option form is a +first-class form on the host, so the pieces that need the host to find a field by +state path work inside it like anywhere else: + +```php +Select::make('category_id') + ->createOptionForm([ + Wizard::make('category')->schema([ // [tl! focus:start] + Step::make('Basics')->schema([ + TextInput::make('name')->required(), + ]), + Step::make('Placement')->schema([ + Select::make('parent_id') + ->getSearchResultsUsing(fn (string $search) => + Category::where('name', 'like', "%{$search}%")->pluck('name', 'id')->all() + ), + ]), + ]), // [tl! focus:end] + ]) + ->createOptionUsing(fn (array $data) => Category::create($data)->getKey()) +``` + +- A [`Wizard`](../../core/schema/layout/wizard.md) gates each step: "Next" validates only that step's + fields and stays put on failure, with the errors landing on the option bag + (`createOptionFormData.*`) where the modal already shows them. +- A nested `Select` reaches the remote-search endpoint, and field actions + (`suffixAction()`, `hintAction()`, `Button`) resolve and run. +- Opening a *second* option modal from inside an option form is refused, not + nested: there is one mounted path and one data bag per kind, so honouring it + would discard the form being filled in. + +Add [`navigation(false)`](../../core/schema/layout/wizard.md#handing-the-navigation-elsewhere) +to the wizard and the **modal footer takes over its navigation** — Back and Next +next to Cancel, with the submit button appearing only on the last step, instead of +a second navigation row inside the panel. Name the wizard when both option modals +can be open at once: the footer and the wizard find each other by that name. + +### Configuring The Option Modal + +Neither option modal is a special case: both are configured through the same +`Modal` object the action modals use, so heading, description, icon, width, close +behaviour, sticky chrome and the two button labels all live in one place. + +```php +use NyonCode\WireCore\Modals\Modal; + +Select::make('category_id') + ->options(fn () => Category::pluck('name', 'id')->all()) + ->createOptionForm([TextInput::make('name')->required()]) + ->createOptionUsing(fn (array $data) => Category::create($data)->getKey()) + ->createOptionModal(fn (Modal $modal) => $modal // [tl! focus:start] + ->heading('New category') + ->description('It becomes selectable as soon as you save it.') + ->icon('outline:folder-plus') + ->width('2xl') + ->closeOnClickAway(false) + ->stickyFooter() + ->submitLabel('Create category') + ->cancelLabel('Discard')) // [tl! focus:end] + ->editOptionModal(fn (Modal $modal) => $modal->width('xl')) +``` + +The callback configures the modal in place; returning a `Modal` replaces it +wholesale. It runs when the schema is defined, not once per render, so text that +depends on state goes through the config's own closure support — +`$modal->heading(fn (Select $field) => …)`, evaluated with the field as context. + +`createOptionModalHeading()` / `createOptionModalWidth()` and their `editOption…` +twins remain as shorthands and write into that same object, so the two ways of +setting a heading cannot drift apart. Width takes a `ModalWidth` case or its token +(`sm`…`7xl`, `full`); an unknown token falls back to `md`, and an unconfigured +modal follows `wire-core.modals.default_width` like every other modal. + +The modal's `id`, `wire:model` and close action are deliberately **not** +configurable. They key the teleport Livewire morphs by, and both option modals can +be mounted at once — a caller-set `id` would let their contents swap. + ## Reactivity The combobox binds deferred by default. Add `live()` when other fields react to the @@ -250,7 +328,9 @@ Select::make('tier') | `preload()` | bool | Eagerly seed the remote option list on render | | `createOptionForm(array\|Closure)` / `createOptionUsing(Closure)` | — | Create a new option from a modal | | `editOptionForm(array\|Closure)` / `fillEditOptionUsing(Closure)` / `updateOptionUsing(Closure)` | — | Edit the selected option from a modal | -| `createOptionModalHeading(string)` / `editOptionModalHeading(string)` | string | Modal headings | +| `createOptionModal(Closure)` / `editOptionModal(Closure)` | — | Configure the option modal through the canonical `Modal` object | +| `createOptionModalHeading(string)` / `editOptionModalHeading(string)` | string | Modal headings (shorthand) | +| `createOptionModalWidth(string\|ModalWidth\|null)` / `editOptionModalWidth(string\|ModalWidth\|null)` | string | Modal widths (`sm`…`7xl`, `full`; default `md`) (shorthand) | | `placeholder(string\|Closure)` | string | Empty/blank option label | | `disabled(bool\|Closure)` | bool | Disable the select | | `required()` | — | Mark as required | diff --git a/docs/forms/fields/time-picker.md b/docs/forms/fields/time-picker.md index 5c544bef..393deb85 100644 --- a/docs/forms/fields/time-picker.md +++ b/docs/forms/fields/time-picker.md @@ -40,6 +40,10 @@ There is no separate `interval()` setter — it is the same concept under the na it already had. `hoursStep()` and `secondsStep()` are inherited but do nothing here: a slot list has one stride, not three. +The interval bounds the **list**, not the value. A time can be typed straight +into the trigger, so `08:07` stays reachable at a 30-minute stride — only the +bounds refuse a typed time. See [Typing](date-time-picker.md#typing). + ## Bounds `minDate()` / `maxDate()` read as times and **disable** the slots outside them, @@ -64,6 +68,7 @@ The value side is entirely inherited, so these behave exactly as documented for TimePicker::make('opens_at') ->withSeconds() // stored H:i:s; slots still land on :00 ->displayFormat('H:i') + ->typeable(false) // the list only — no typing into the trigger ->native() // browser's ->placeholder('Pick a time') ``` diff --git a/docs/forms/fields/tiptap-editor.md b/docs/forms/fields/tiptap-editor.md index 91a2d73f..96ca3be1 100644 --- a/docs/forms/fields/tiptap-editor.md +++ b/docs/forms/fields/tiptap-editor.md @@ -22,12 +22,16 @@ The ` +``` + +Nothing to run, nothing to configure. The copy is incremental — a file already +present and current is left alone — so in steady state a request does a handful of +`stat` calls and no writes at all. After an upgrade it is one copy per changed +bundle, on one request. Copies land through a temporary file and an atomic rename, +so a browser fetching a bundle mid-copy never receives a half-written one. + +This matters more than it sounds. Serving the bundles from a package *route* only +works if the request reaches PHP, and a very common web-server layout answers `.js` +itself: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404 +} +``` + +On shared hosting that block is frequently not yours to change — and it breaks +Livewire's own `/livewire/livewire.js` in exactly the same way. A file that exists +is served by every web server configuration there is, so that is what the packages +ship you. + +**Publishing is still supported** and does the same copy ahead of time, which moves +it off the first request after a deploy: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +`laravel-assets` is the tag the Laravel skeleton already runs from its composer +`post-update-cmd`, so `composer update` keeps the copies current on its own. Neither +the command nor the hook is required. + +### If `public/` is not writable + +A read-only container, Vapor, a hardened deployment: nothing throws. The package +route serves the bundles exactly as it did before, and you want either the publish +command above (run at build time, when the filesystem still is writable) or the +`try_files … /index.php?$query_string` fall-through so the route is reachable. + +If an *older* copy is already there, it keeps being served rather than falling back +to a route that may be unreachable — and the console says so, on every page and +regardless of `APP_DEBUG`, naming the bundles and the command that fixes them. See +[Troubleshooting](troubleshooting.md#javascript-404s-and-wirex-is-not-defined). + ## Config Publishing (optional) ```bash diff --git a/docs/sortable/api-reference.md b/docs/sortable/api-reference.md index 779856a9..68aba819 100644 --- a/docs/sortable/api-reference.md +++ b/docs/sortable/api-reference.md @@ -105,9 +105,12 @@ Handle row drag & drop. Called by Alpine.js after a drag operation completes. Up Each item: `['value' => string|int, 'order' => int]` +`order` is the row's new position on screen, not the value written. The dragged rows keep the set of order values they already held, redistributed in the new visual sequence, so a drag over a searched, filtered or paginated subset cannot move the rows it does not show — see [Reordering a narrowed list](row-sorting.md#reordering-a-narrowed-list). The positions are written verbatim only when the order column is null or constant and has nothing to redistribute. + No-op if: - The table is not reorderable - The table is not in reorder mode (`$isReordering === false`) +- A row's key falls outside the table's base query (it is dropped from the write) #### `reorderColumns(array $columnOrder): void` @@ -140,7 +143,7 @@ Returns `'wire-sortable::tables.index'` when row or column reordering is enabled #### `interceptTableRecords(): LengthAwarePaginator|Paginator|CursorPaginator|Collection|null` -In reorder mode (without `paginatedWhileReordering`): bypasses search, filters, sorting, and pagination. Returns all records ordered by the sort column ascending. +In reorder mode (without `paginatedWhileReordering`): bypasses pagination and the column sort, but keeps search and filters applied. Returns every matching record ordered by the sort column ascending. Otherwise: returns `null` to let `WithTable` handle record fetching normally. diff --git a/docs/sortable/row-sorting.md b/docs/sortable/row-sorting.md index 8b301d5d..40569cce 100644 --- a/docs/sortable/row-sorting.md +++ b/docs/sortable/row-sorting.md @@ -56,12 +56,18 @@ The Blade template uses the computed `$table` property: 3. In reorder mode: - Drag handles appear on each row - Pagination is disabled (all records are shown) - - Sorting, search, and filters are bypassed - - Rows are ordered by the sort column ascending + - The column sort is bypassed -- rows are ordered by the sort column ascending + - **Search and filters stay applied**, so the list can still be narrowed 4. User drags rows to their desired position 5. On drag end, the new order is saved to the database 6. User clicks **"Done reordering"** to exit reorder mode -7. The table returns to its normal state with pagination, sorting, and filters restored +7. The table returns to its normal state with pagination and the column sort restored + +The column sort has to give way because the sequence on screen is the sequence a +drop writes back: it can only ever be the order column's. Search and filters do +not, because they change *which* rows can be dragged, not what dragging means -- +see [Reordering a narrowed list](#reordering-a-narrowed-list) for why that is +safe. ## Custom order column @@ -94,7 +100,7 @@ return $table ->columns([...]); ``` -In this mode the table is always in reorder mode -- no toggle button is rendered and `$isReordering` is set to `true` on mount. +In this mode the table is always in reorder mode -- no toggle button is rendered and `$isReordering` is set to `true` on mount. There is no way back to a plain table, which is exactly why reorder mode keeps search and filters working: the search box on an always-reorderable table would otherwise never do anything. ## Conditional reordering @@ -121,7 +127,36 @@ return $table ->columns([...]); ``` -> **Note:** With pagination enabled, users can only reorder within the current page. +> **Note:** With pagination enabled, users can only reorder within the current page. A drop rearranges that page's rows among themselves and leaves every other page where it was. + +## Reordering a narrowed list + +A user in reorder mode can still search, filter and -- with +`paginatedWhileReordering()` -- page. So a drag usually happens over a *subset* +of the table, and the rows that subset hides must not move. + +They do not, because a drop does not number the rows it was given. It collects +the order values those rows already hold, sorts them ascending, and hands them +back out in the new visual sequence: + +```php +// Rows holding sort_order 10, 20, 30. Drag the last one to the top: +// before after +// A 10 C 10 +// B 20 A 20 +// C 30 B 30 +``` + +Three consequences worth knowing: + +- **Rows outside the drag never move.** They keep their slots, so a search for + `audit` can reorder the four matching rows without disturbing the four hundred + it hid. +- **Gaps are preserved.** An order column of `10, 20, 30` stays `10, 20, 30`. If + you leave gaps to insert into later, reordering does not close them. +- **An empty or constant order column has nothing to redistribute.** There, and + only there, the client's own positions (`1..n`) are written instead -- which is + the correct answer for a column that carried no ordering to begin with. ## Lifecycle hooks diff --git a/docs/table/advanced.md b/docs/table/advanced.md index 6f11a4e9..0f5c9be4 100644 --- a/docs/table/advanced.md +++ b/docs/table/advanced.md @@ -728,6 +728,78 @@ action still shows that action inline. The dropdown inherits the table's `sheetOnMobile()` / `mobileBreakpoint()` settings (bottom-sheet on small screens by default). +### Header Actions on a Phone + +The toolbar carries the same crowding problem one level up: the search field, +the filter trigger and the view menu already sit there, and two labelled header +buttons ("New invoice", "Import CSV") push the row into a wrap at phone width. +`collapseHeaderActionsOnMobile()` folds them into one dropdown: + +```php +$table->collapseHeaderActionsOnMobile() // one "⋮" trigger instead of the buttons +``` + +Unlike `collapseActionsOnMobile()` this needs no `stackedOnMobile()` — the +toolbar is the same toolbar at every width, so the collapse is purely a width +switch. The switch is the table's **`mobileBreakpoint()`** (`sm` by default, +i.e. below 640px), not the stacking breakpoint: + +```php +$table + ->mobileBreakpoint('md') // fold below 768px instead + ->collapseHeaderActionsOnMobile() +``` + +It folds from **2** executable header actions up — a lone button is not a crowd, +and the toolbar folds sooner than a card's row actions because it shares its row +with the search field. Tune it the same way: + +```php +->collapseHeaderActionsOnMobile(threshold: 3) // keep two buttons inline, fold from three +->collapseHeaderActionsOnMobile(threshold: 1) // always fold +``` + +Only actions the viewer may run are counted, so a table whose second action is +gated by an authorization guard keeps the first one as a plain button. The +dropdown is the canonical `ActionGroup` — it inherits `sheetOnMobile()` / +`mobileBreakpoint()`, so it opens as a bottom sheet on a phone by default, and +it collapses to a single inline button when only one action survives its guards. + +Both halves sit in the document at every width (CSS decides which is shown), so +the folded copy renders **without** each action's `keyboardShortcut()`: a +rendered shortcut is a *window* listener, and a second binding would run the +action twice on one keypress. The visible desktop button keeps it. + +```php +class ListInvoices extends Component +{ + use WithTable; + + public function table(Table $table): Table + { + return $table + ->model(Invoice::class) + ->columns([ + TextColumn::make('number'), + TextColumn::make('total')->money('CZK'), + ]) + ->headerActions([ + HeaderAction::make('create') // [tl! focus:start] + ->label('New invoice') + ->icon('plus') + ->keyboardShortcut('c') // desktop only — see above + ->url(route('invoices.create')), + + HeaderAction::make('import') + ->label('Import CSV') + ->icon('arrow-up-tray') + ->action(fn () => $this->importInvoices()), + ]) + ->collapseHeaderActionsOnMobile(); // [tl! focus:end] + } +} +``` + ### The Card's Anatomy A card is a record, not the column order in disguise. Five named slots carry the diff --git a/docs/table/columns/index.md b/docs/table/columns/index.md index 31d2d39b..4c6e5fa0 100644 --- a/docs/table/columns/index.md +++ b/docs/table/columns/index.md @@ -120,6 +120,11 @@ A column left as text is skipped by a comparison rather than compared lexically, so a wrong or missing declaration narrows what search understands — it never returns wrong rows. +The declaration alone switches nothing on. A searchable column that declares a +type while the table's search does not read ranges is refused when the table +renders, naming the call it is missing — the alternative is a table that comes +back empty because `10..20` was looked for as literal text. + `'code'` is the one type that is *never* inferred: it says the value is a series plus a **zero-padded** number (`8866 01`, `8866 02`), which is what makes comparing it as text correct, and only the owner knows that. It unlocks diff --git a/docs/table/overview.md b/docs/table/overview.md index a98ba579..3746c9bb 100644 --- a/docs/table/overview.md +++ b/docs/table/overview.md @@ -329,12 +329,24 @@ number. Declare the column with over directly: ```php -TextColumn::make('reference')->searchable()->searchAs('code'); +$table + ->searchable() + ->search(fn (SearchConfig $s) => $s->tokenize()->ranges()) + ->columns([ + TextColumn::make('reference')->searchable()->searchAs('code'), + ]); // User types: 8866 01..08 // SQL: reference BETWEEN '8866 01' AND '8866 08' ``` +Both halves are required: `searchAs('code')` says what the column holds, +`ranges()` is what lets a range be typed at all, and `tokenize()` is what splits +the series from the sequence. A declaration the search box cannot ask for is +refused when the table renders — with `ranges()` off, `8866 01..08` would +otherwise be looked for as literal text and the table would come back empty with +nothing on screen to explain it. + The space inside the code is also what splits the term, so `8866 01..08` arrives as the word `8866` and the range `01..08`. The range carries the word directly before it, and a code column completes both bounds with it — write the @@ -348,9 +360,11 @@ Two rules keep it honest: - **The number must be stored padded, and typed the way it is stored.** Comparing as text is only correct while the width is constant (`01 … 08` sorts alphabetically in the same order it sorts numerically; `9 … 10` does - not). Typing `1..8` against stored `01 … 08` finds nothing. A range typed - across a width boundary is completed for you — `8866 50..100` is read as - `050..100`, since a hundredth member can only exist in a three-digit series. + not). A range is compared at the width it was typed, so `1..8` against stored + `01 … 08` is `BETWEEN '8866 1' AND '8866 8'` and matches by text — it misses + the whole padded series and reaches `8866 12` instead. A range typed across a + width boundary is completed for you — `8866 50..100` is read as `050..100`, + since a hundredth member can only exist in a three-digit series. - **The series is the one word before the range.** `faktura 8866 01..08` ranges inside `8866` and requires `faktura` separately; a code containing two spaces is out of reach. @@ -530,6 +544,9 @@ be combined on the same table: // Collapse the mobile card's row actions into one dropdown group (from N actions up) ->collapseActionsOnMobile(bool $collapse = true, int $threshold = 3) + +// The same for the toolbar's header actions, below the table's mobileBreakpoint() +->collapseHeaderActionsOnMobile(bool $collapse = true, int $threshold = 2) ``` ### Empty State diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8890c73f..cb1b7e48 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -134,6 +134,59 @@ See [Getting Started → JavaScript Assets](getting-started.md#javascript-assets --- +## JavaScript 404s and `wireX is not defined` + +**Symptom:** The same `ReferenceError` as the previous entry, but on *every* page +and however you reached it — a hard reload shows it too. The network tab has 404s on +`/wire-core/assets/dropdown.js`, `/wire-table/assets/records.js`, or a sibling +under `/wire-forms/…` or `/wire-sortable/…`. + +**Cause:** Two things went wrong together. The packages normally copy their bundles +into `public/vendor/` and emit *those* paths, so nothing hits PHP — a +`/wire-core/assets/…` URL in your markup means that copy could not be made and the +package route is standing in for it. And your web server is answering the route +itself instead of forwarding it to PHP. The stock Laravel nginx config sends +anything not on disk to `index.php`, but a config with a static-asset block does not: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404, PHP never sees it +} +``` + +Nothing here is package-specific: the same block 404s Livewire's own +`/livewire/livewire.js`. + +**Fix — make `public/` writable, or write it at build time.** The usual cause is a +`public/` the web user cannot write to, or a read-only container. Either grant the +write, or do the copy while the filesystem still is writable: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +**Or make the route reachable**, by letting the block fall through to the front +controller — the right answer where a writable `public/` is genuinely not on offer: + +```nginx +location ~* \.(js|css)$ { + try_files $uri /index.php?$query_string; // [tl! focus] +} +``` + +A related warning, when copies exist but could not be refreshed after an upgrade: + +```text +wireStack: the published copies of wire-core/dropdown are older than the bundles +the packages ship, and are what this page just loaded. +``` + +The page still works — an old bundle beats no bundle — but the same writability +problem is behind it. See +[Getting Started → JavaScript Assets](getting-started.md#javascript-assets). + +--- + ## Reordering stops working, or my own code loses `window.Sortable` **Symptom:** After upgrading, your application's own JavaScript throws diff --git a/docs/upgrade.md b/docs/upgrade.md index 05f5bbbb..0c344dfb 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -35,14 +35,40 @@ the changelog before bumping: | Dependency | Supported | |------------|-----------| | PHP | 8.2, 8.3, 8.4 | -| Laravel | 10, 11, 12 | +| Laravel | 12.61+, 13.12+ | | Livewire | 3.x | | Tailwind CSS | 3.x or 4.x | +| `nyoncode/laravel-package-toolkit` | ^2.4 | Confirm your app meets these before upgrading. --- +## Dependency floors (1.17) + +**Laravel 10 and 11 are gone.** 1.17 moved the JavaScript bundles from a package +route to real files under `public/vendor`, and the code that mirrors them lives in +`nyoncode/laravel-package-toolkit` — next to the `hasAssets()` declaration and the +publish tag it is the read side of. The toolkit is on `illuminate/support ^12.61.1|^13.12.0`, +and a dependency's floor is your floor: an app below it cannot resolve the Wire +packages, whatever the `^12.0` in their own `composer.json` says. Upgrade Laravel +first, then Wire. + +**The toolkit constraint is `^2.4`.** You do not require it directly, so in the +normal case `composer update "nyoncode/wire-*"` moves it with everything else and +there is nothing to do. It only becomes visible in two shapes: + +- your `composer.json` names `nyoncode/laravel-package-toolkit` — from building + your own package on it, or from an old pin — and holds it below 2.4. Composer + reports the Wire packages as uninstallable rather than the toolkit as too old, + so widen that constraint to `^2.4` first. +- you run Octane. The per-worker asset memo is flushed on `RequestTerminated` + through the toolkit's `PublishedAssets::flush()`, which 2.4 is the first release + to carry. Below it, a worker that survives a deploy keeps emitting the previous + release's `?id=` and `wire:navigate` never notices the new bundles. + +--- + ## Upgrade Steps 1. **Read the changelog.** Check `CHANGELOG.md` for the versions you are crossing, diff --git a/package.json b/package.json index 869588a1..3465496c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "docs:standard": "node docs-site/scripts/verify-docs-standard.mjs .", "docs:changed": "bash scripts/docs-changed.sh", "build:forms-assets": "rm -rf packages/forms/dist/tiptap && esbuild packages/forms/resources/js/tiptap-editor.js packages/forms/resources/js/tiptap-editor-addons.js --bundle --minify --format=esm --splitting --outdir=packages/forms/dist/tiptap --entry-names=[name] --chunk-names=chunk-[hash] --legal-comments=none && esbuild packages/forms/resources/js/image-processor.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/forms/dist/wire-forms-image.js", - "build:core-assets": "esbuild packages/core/resources/js/dropdown.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/core/dist/wire-core-dropdown.js && esbuild packages/core/resources/js/chart.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/core/dist/wire-core-chart.js", + "build:core-assets": "esbuild packages/core/resources/js/dropdown.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/core/dist/wire-core-dropdown.js && esbuild packages/core/resources/js/chart.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/core/dist/wire-core-chart.js && esbuild packages/core/resources/js/copy.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/core/dist/wire-core-copy.js", "build:sortable-assets": "esbuild packages/sortable/resources/js/sortable.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/sortable/dist/wire-sortable.js", "build:table-assets": "esbuild packages/table/resources/js/record-actions.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-records.js && esbuild packages/table/resources/js/record-selection.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-selection.js && esbuild packages/table/resources/js/record-live.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-live.js", "build:workbench-echo": "esbuild workbench/resources/js/echo-bootstrap.js --bundle --format=iife --outfile=workbench/resources/dist/echo-bootstrap.js", diff --git a/packages/boost/composer.json b/packages/boost/composer.json index bc198d0f..deda20b3 100644 --- a/packages/boost/composer.json +++ b/packages/boost/composer.json @@ -12,16 +12,16 @@ "require": { "php": "^8.2", "nyoncode/wire-core": "^1.0|@dev", - "illuminate/support": "^11.0|^12.0|^13.0", - "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "illuminate/console": "^12.0|^13.0", "laravel/mcp": "^0.8", "livewire/livewire": "^3.0", - "nyoncode/laravel-package-toolkit": "^2.0.1" + "nyoncode/laravel-package-toolkit": "^2.4" }, "require-dev": { - "orchestra/testbench": "^9.0|^10.0|^11.0", + "orchestra/testbench": "^10.0|^11.0", "laravel/pint": "^1.29", - "pestphp/pest": "^2.0|^3.0|^4.0" + "pestphp/pest": "^4.0|^5.0" }, "suggest": { "nyoncode/wire-forms": "Enables form introspection tools.", diff --git a/packages/boost/resources/boost/docs/configuration.md b/packages/boost/resources/boost/docs/configuration.md index 4f184a59..5763ad5b 100644 --- a/packages/boost/resources/boost/docs/configuration.md +++ b/packages/boost/resources/boost/docs/configuration.md @@ -31,9 +31,10 @@ You only need the tags for packages you installed. ## JavaScript Assets -There is no asset configuration and nothing to publish: every package serves its -own pre-built bundles from its own route, cache-busted by file modification time. -The one thing your app decides is *where* they are emitted — put +Nothing needs configuring and nothing needs publishing: every package copies its +own pre-built bundles into `public/vendor/` and serves them as static +files, cache-busted by file modification time. The one thing your app decides is +*where* they are emitted — put ```blade @wireStackScripts @@ -44,6 +45,10 @@ the initial document, which is what keeps them working across `wire:navigate` (including the cached Back/Forward path). Pass a package name — `@wireStackScripts('wire-table')` — to emit only one package's bundles. +`php artisan vendor:publish --tag=laravel-assets --force` does the same copy ahead +of time, which moves it off the first request after a deploy — useful, never +required. There is no config key either way. + Full explanation in [Getting Started → JavaScript Assets](getting-started.md#javascript-assets). ## Core diff --git a/packages/boost/resources/boost/docs/core/schema/layout/wizard.md b/packages/boost/resources/boost/docs/core/schema/layout/wizard.md index 8798abbc..b6fa63f0 100644 --- a/packages/boost/resources/boost/docs/core/schema/layout/wizard.md +++ b/packages/boost/resources/boost/docs/core/schema/layout/wizard.md @@ -56,12 +56,46 @@ Multiple wizards on one host are addressed by name — give each a name (`Wizard::make('signup')`) so its steps validate independently; an unnamed wizard resolves to the first one in the schema. +## Handing The Navigation Elsewhere + +`navigation(false)` renders the wizard without its Previous / Next row, for a +surface that wants those controls in its own chrome — a modal footer, a page +toolbar — so two navigations do not sit on screen at once: + +```php +Wizard::make('category') + ->navigation(false) // [tl! focus] + ->schema([ + Step::make('Name')->schema([TextInput::make('label')->required()]), + Step::make('Detail')->schema([TextInput::make('note')]), + ]) +``` + +The wizard still owns the step state; the outer surface mirrors and steps it over +two window events, because a driving footer is a *sibling* subtree and a bubbling +event would never reach it: + +- `wire-wizard-state` — published by the wizard whenever its step, total or + validating flag changes: `{ wizard, step, total, validating }`. +- `wire-wizard-navigate` — sent to the wizard to move: `{ wizard, direction }` + where direction is `'next'` or `'previous'`. `'next'` runs the same per-step + validation the built-in button does, so an external control gates identically. + +Both are scoped by `wizard` — the wizard's name, `null` when unnamed. Name the +wizard whenever two can be on screen at once, or they share an empty scope. + +A [`Select`'s option modal](../../../forms/fields/select.md#a-full-form-not-a-field-list) +does this for you: put a `navigation(false)` wizard in `createOptionForm()` and +the modal footer takes over, showing Back / Next until the last step and the +submit button only there. + ## Methods | Method | On | Description | |--------|----|-------------| | `activeStep(int)` | `Wizard` | Zero-based index of the step shown first | | `skippable()` | `Wizard` | Allow jumping to any step from the indicator | +| `navigation(bool)` | `Wizard` | Render without the built-in Previous / Next row, for an outer surface to drive | | `description(string)` | `Step` | Secondary line under the step label | | `icon(string\|Icon)` | `Step` | Step icon | | `columns(int)` | `Step` | Column grid for the step's child schema | diff --git a/packages/boost/resources/boost/docs/forms/custom-fields.md b/packages/boost/resources/boost/docs/forms/custom-fields.md index 3e616b2d..e9e2539b 100644 --- a/packages/boost/resources/boost/docs/forms/custom-fields.md +++ b/packages/boost/resources/boost/docs/forms/custom-fields.md @@ -732,25 +732,30 @@ emitted twice on one page (a per-surface include plus [`@wireStackScripts`](../getting-started.md#javascript-assets)), and the browser will execute it both times. -If your package ships more than an occasional heavy field, declare the bundle -with the shared `AssetManager` from your own service provider's boot instead of -only including it per surface, and `@wireStackScripts` will emit it alongside -Wire's own: +If your package ships more than an occasional heavy field, declare the bundle in +your own package's `configure()` instead of only including it per surface, and +`@wireStackScripts` will emit it alongside Wire's own: ```php -use NyonCode\WireCore\Foundation\Assets\AssetManager; -use NyonCode\WireCore\Foundation\Assets\Js; +use NyonCode\WireCore\Foundation\Assets\Bundle; -app(AssetManager::class)->register([ - Js::make('my-field', __DIR__.'/../dist/my-field.js')->navigateTrack(), -], 'my-package'); +$packager + ->hasAssets('dist', entries: [ + Bundle::make('my-field.js'), + ]) + ->hasAssetFallback(Bundle::servedByRoute('my-package')); ``` -`Js::make()` takes the bundle id and a **filesystem** path (that is where the -`?id=` cache-buster comes from) and resolves its URL from your package's -`{package}.asset` named route. Keep heavy bodies off pages that do not need them -with `->loadedOnRequest()` — but never the small controller that registers the -component. +Entries are keyed by the **shipped filename**, relative to the asset directory. +`Bundle::make()` declares what every Wire bundle is — a classic (non-module) +script, because an ES module's top-level declarations never reach `window` and +your registration would silently do nothing. `hasAssetFallback()` keeps the tag +alive where `public/` cannot be written, by pointing at your package's own +`{package}.asset` route. + +Keep heavy bodies off pages that do not need them by leaving them out of +`entries:` and having the field deliver them per surface — but never the small +controller that registers the component. --- diff --git a/packages/boost/resources/boost/docs/forms/fields/date-time-picker.md b/packages/boost/resources/boost/docs/forms/fields/date-time-picker.md index 9bfffdae..3d56b49e 100644 --- a/packages/boost/resources/boost/docs/forms/fields/date-time-picker.md +++ b/packages/boost/resources/boost/docs/forms/fields/date-time-picker.md @@ -96,6 +96,50 @@ DateTimePicker::make('date') > the stored value is untouched. It is honoured by the custom picker; a native > input's display format belongs to the browser and the user's locale. +## Typing + +The trigger is a text box, not a button: the value can be typed as well as +picked. What is typed is read back through the same format the box shows — +`displayFormat()` when there is one, the stored shape otherwise — so a field +displaying `9. 3. 2026 14:30` accepts exactly that back. + +The parser is loose about everything except the *order* of the parts, which the +format fixes. Under `->displayFormat('j. n. Y H:i')` all of these land on the +same value: + +```text +9. 3. 2026 14:30 +9.3.2026 14:30 +9/3/2026 14:30 +9. 3. 26 14:30 a two-digit year is this century +9. 3. 2026 no clock typed, so the time already showing is kept +``` + +The entry commits on blur and on Enter; Escape abandons it. +Anything the parser cannot read — `31. 2. 2026`, an hour past 23, a day that +`minDate()`/`maxDate()`/`disabledDates()` exclude — is refused whole and the +previous value comes back, so a half-read date can never reach the state. +Emptying the box clears the field. + +A typed value goes through the same clamp a picked one does: on a boundary day +that carries a time, the clock is pulled inside the bound rather than rejected — +typing `10. 3. 2026 07:00` under `->minDate('2026-03-10 08:30')` stores 08:30. + +Close the keyboard route where the value really must come from the widget: + +```php +DateTimePicker::make('slot')->typeable(false) +``` + +> `readOnly()` outranks `typeable()`: it closes the keyboard *and* the panel, +> because the value is not the user's to change by any route. `typeable(false)` +> closes only the keyboard and leaves the calendar working. + +> Typing is a custom-picker feature. A native input's keyboard belongs to the +> browser, and the only way to take it away is `readonly` — which would disable +> the browser's own picker along with it — so `typeable(false)` has no effect +> under `->native()`. + ## Native Picker The custom Alpine picker is the default. Opt out to the browser's own control: @@ -130,8 +174,9 @@ The only exception is [`asMonth()`](#modes), which is always native. | `secondsStep(int)` | int | Second increment step | | `timezone(string)` | string | Show the value in this timezone and convert back to the app timezone on save; `datetime` only | | `native(bool $native = true)` | bool | Use the browser-native control instead of the custom picker (default: `false`) | +| `typeable(bool\|Closure)` | bool | Let the value be typed into the input as well as picked (default: `true`); custom picker only | | `disabled(bool\|Closure)` | bool | Disable the picker | -| `readOnly(bool\|Closure)` | bool | Read-only mode | +| `readOnly(bool\|Closure)` | bool | Read-only mode — no typing and no panel | | `required()` | — | Mark as required | | `live()` | — | Trigger Livewire update on change | diff --git a/packages/boost/resources/boost/docs/forms/fields/markdown-editor.md b/packages/boost/resources/boost/docs/forms/fields/markdown-editor.md index 7f9cd623..6fce3a2c 100644 --- a/packages/boost/resources/boost/docs/forms/fields/markdown-editor.md +++ b/packages/boost/resources/boost/docs/forms/fields/markdown-editor.md @@ -59,6 +59,24 @@ The toolbar provides keyboard-accessible buttons for: The built-in preview handles: headings (`#`, `##`, `###`), bold/italic/strikethrough, inline code, links, blockquotes, and unordered/ordered lists. For full GFM rendering, post-process the stored Markdown on the server side using a library like [CommonMark](https://commonmark.thephpleague.com/). +The preview runs in the browser and writes through `x-html`, so raw HTML in the Markdown is **escaped, not rendered**: `` shows as text. Link URLs are additionally restricted to `http(s):`, `mailto:`, `#` and root-relative paths — anything else becomes `#`, so a `javascript:` link cannot be planted through the preview. + +## Localization + +Toolbar tooltips and the Write/Preview tab labels come from the shared editor +vocabulary `wire-forms::fields.editor.*` — the same keys +[TiptapEditor](tiptap-editor.md#localization) and +[RichEditor](rich-editor.md#localization) use, so the three editors read alike in +every locale. English (`en`) and Czech (`cs`) ship with the package; a Czech app +shows *Tučné*, *Kód v textu*, and the tabs *Psát* / *Náhled*. + +Reword a string, or add a locale, by publishing the translations and editing +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Methods | Method | Type | Description | diff --git a/packages/boost/resources/boost/docs/forms/fields/rich-editor.md b/packages/boost/resources/boost/docs/forms/fields/rich-editor.md index 47e19810..f15df440 100644 --- a/packages/boost/resources/boost/docs/forms/fields/rich-editor.md +++ b/packages/boost/resources/boost/docs/forms/fields/rich-editor.md @@ -61,6 +61,22 @@ RichEditor::make('summary') ->maxLength(500) ``` +## Localization + +Toolbar tooltips and the link prompt come from the shared editor vocabulary +`wire-forms::fields.editor.*` — the same keys +[TiptapEditor](tiptap-editor.md#localization) and +[MarkdownEditor](markdown-editor.md#localization) use, so the three editors read +alike in every locale. English (`en`) and Czech (`cs`) ship with the package; a +Czech app shows *Tučné*, *Číslovaný seznam*, *Nadpis 2*, and prompts *URL odkazu*. + +Reword a string, or add a locale, by publishing the translations and editing +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Methods | Method | Type | Description | diff --git a/packages/boost/resources/boost/docs/forms/fields/select.md b/packages/boost/resources/boost/docs/forms/fields/select.md index 4a83a553..2b3e59ce 100644 --- a/packages/boost/resources/boost/docs/forms/fields/select.md +++ b/packages/boost/resources/boost/docs/forms/fields/select.md @@ -146,7 +146,6 @@ errors; on success the new value is selected (appended for a multi-select). - `createOptionUsing()` returns the new option's value — a scalar key, or a model whose key is used. - Editing targets the single selected option, so it is unavailable on `multiple()`. -- `createOptionModalHeading()` / `editOptionModalHeading()` customise the headings. - Works in standalone `WithForms` components **and** inside table action modals. - So the newly created value renders a label, pair with `getOptionLabelUsing()` or a preloaded option list. @@ -154,6 +153,85 @@ errors; on success the new value is selected (appended for a multi-select). dispatches `select-option-created` / `select-option-updated` browser events) — no page refresh needed. +### A Full Form, Not A Field List + +The option schema is an ordinary form schema and the mounted option form is a +first-class form on the host, so the pieces that need the host to find a field by +state path work inside it like anywhere else: + +```php +Select::make('category_id') + ->createOptionForm([ + Wizard::make('category')->schema([ // [tl! focus:start] + Step::make('Basics')->schema([ + TextInput::make('name')->required(), + ]), + Step::make('Placement')->schema([ + Select::make('parent_id') + ->getSearchResultsUsing(fn (string $search) => + Category::where('name', 'like', "%{$search}%")->pluck('name', 'id')->all() + ), + ]), + ]), // [tl! focus:end] + ]) + ->createOptionUsing(fn (array $data) => Category::create($data)->getKey()) +``` + +- A [`Wizard`](../../core/schema/layout/wizard.md) gates each step: "Next" validates only that step's + fields and stays put on failure, with the errors landing on the option bag + (`createOptionFormData.*`) where the modal already shows them. +- A nested `Select` reaches the remote-search endpoint, and field actions + (`suffixAction()`, `hintAction()`, `Button`) resolve and run. +- Opening a *second* option modal from inside an option form is refused, not + nested: there is one mounted path and one data bag per kind, so honouring it + would discard the form being filled in. + +Add [`navigation(false)`](../../core/schema/layout/wizard.md#handing-the-navigation-elsewhere) +to the wizard and the **modal footer takes over its navigation** — Back and Next +next to Cancel, with the submit button appearing only on the last step, instead of +a second navigation row inside the panel. Name the wizard when both option modals +can be open at once: the footer and the wizard find each other by that name. + +### Configuring The Option Modal + +Neither option modal is a special case: both are configured through the same +`Modal` object the action modals use, so heading, description, icon, width, close +behaviour, sticky chrome and the two button labels all live in one place. + +```php +use NyonCode\WireCore\Modals\Modal; + +Select::make('category_id') + ->options(fn () => Category::pluck('name', 'id')->all()) + ->createOptionForm([TextInput::make('name')->required()]) + ->createOptionUsing(fn (array $data) => Category::create($data)->getKey()) + ->createOptionModal(fn (Modal $modal) => $modal // [tl! focus:start] + ->heading('New category') + ->description('It becomes selectable as soon as you save it.') + ->icon('outline:folder-plus') + ->width('2xl') + ->closeOnClickAway(false) + ->stickyFooter() + ->submitLabel('Create category') + ->cancelLabel('Discard')) // [tl! focus:end] + ->editOptionModal(fn (Modal $modal) => $modal->width('xl')) +``` + +The callback configures the modal in place; returning a `Modal` replaces it +wholesale. It runs when the schema is defined, not once per render, so text that +depends on state goes through the config's own closure support — +`$modal->heading(fn (Select $field) => …)`, evaluated with the field as context. + +`createOptionModalHeading()` / `createOptionModalWidth()` and their `editOption…` +twins remain as shorthands and write into that same object, so the two ways of +setting a heading cannot drift apart. Width takes a `ModalWidth` case or its token +(`sm`…`7xl`, `full`); an unknown token falls back to `md`, and an unconfigured +modal follows `wire-core.modals.default_width` like every other modal. + +The modal's `id`, `wire:model` and close action are deliberately **not** +configurable. They key the teleport Livewire morphs by, and both option modals can +be mounted at once — a caller-set `id` would let their contents swap. + ## Reactivity The combobox binds deferred by default. Add `live()` when other fields react to the @@ -250,7 +328,9 @@ Select::make('tier') | `preload()` | bool | Eagerly seed the remote option list on render | | `createOptionForm(array\|Closure)` / `createOptionUsing(Closure)` | — | Create a new option from a modal | | `editOptionForm(array\|Closure)` / `fillEditOptionUsing(Closure)` / `updateOptionUsing(Closure)` | — | Edit the selected option from a modal | -| `createOptionModalHeading(string)` / `editOptionModalHeading(string)` | string | Modal headings | +| `createOptionModal(Closure)` / `editOptionModal(Closure)` | — | Configure the option modal through the canonical `Modal` object | +| `createOptionModalHeading(string)` / `editOptionModalHeading(string)` | string | Modal headings (shorthand) | +| `createOptionModalWidth(string\|ModalWidth\|null)` / `editOptionModalWidth(string\|ModalWidth\|null)` | string | Modal widths (`sm`…`7xl`, `full`; default `md`) (shorthand) | | `placeholder(string\|Closure)` | string | Empty/blank option label | | `disabled(bool\|Closure)` | bool | Disable the select | | `required()` | — | Mark as required | diff --git a/packages/boost/resources/boost/docs/forms/fields/time-picker.md b/packages/boost/resources/boost/docs/forms/fields/time-picker.md index 5c544bef..393deb85 100644 --- a/packages/boost/resources/boost/docs/forms/fields/time-picker.md +++ b/packages/boost/resources/boost/docs/forms/fields/time-picker.md @@ -40,6 +40,10 @@ There is no separate `interval()` setter — it is the same concept under the na it already had. `hoursStep()` and `secondsStep()` are inherited but do nothing here: a slot list has one stride, not three. +The interval bounds the **list**, not the value. A time can be typed straight +into the trigger, so `08:07` stays reachable at a 30-minute stride — only the +bounds refuse a typed time. See [Typing](date-time-picker.md#typing). + ## Bounds `minDate()` / `maxDate()` read as times and **disable** the slots outside them, @@ -64,6 +68,7 @@ The value side is entirely inherited, so these behave exactly as documented for TimePicker::make('opens_at') ->withSeconds() // stored H:i:s; slots still land on :00 ->displayFormat('H:i') + ->typeable(false) // the list only — no typing into the trigger ->native() // browser's ->placeholder('Pick a time') ``` diff --git a/packages/boost/resources/boost/docs/forms/fields/tiptap-editor.md b/packages/boost/resources/boost/docs/forms/fields/tiptap-editor.md index 91a2d73f..96ca3be1 100644 --- a/packages/boost/resources/boost/docs/forms/fields/tiptap-editor.md +++ b/packages/boost/resources/boost/docs/forms/fields/tiptap-editor.md @@ -22,12 +22,16 @@ The ` +``` + +Nothing to run, nothing to configure. The copy is incremental — a file already +present and current is left alone — so in steady state a request does a handful of +`stat` calls and no writes at all. After an upgrade it is one copy per changed +bundle, on one request. Copies land through a temporary file and an atomic rename, +so a browser fetching a bundle mid-copy never receives a half-written one. + +This matters more than it sounds. Serving the bundles from a package *route* only +works if the request reaches PHP, and a very common web-server layout answers `.js` +itself: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404 +} +``` + +On shared hosting that block is frequently not yours to change — and it breaks +Livewire's own `/livewire/livewire.js` in exactly the same way. A file that exists +is served by every web server configuration there is, so that is what the packages +ship you. + +**Publishing is still supported** and does the same copy ahead of time, which moves +it off the first request after a deploy: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +`laravel-assets` is the tag the Laravel skeleton already runs from its composer +`post-update-cmd`, so `composer update` keeps the copies current on its own. Neither +the command nor the hook is required. + +### If `public/` is not writable + +A read-only container, Vapor, a hardened deployment: nothing throws. The package +route serves the bundles exactly as it did before, and you want either the publish +command above (run at build time, when the filesystem still is writable) or the +`try_files … /index.php?$query_string` fall-through so the route is reachable. + +If an *older* copy is already there, it keeps being served rather than falling back +to a route that may be unreachable — and the console says so, on every page and +regardless of `APP_DEBUG`, naming the bundles and the command that fixes them. See +[Troubleshooting](troubleshooting.md#javascript-404s-and-wirex-is-not-defined). + ## Config Publishing (optional) ```bash diff --git a/packages/boost/resources/boost/docs/sortable/api-reference.md b/packages/boost/resources/boost/docs/sortable/api-reference.md index 779856a9..68aba819 100644 --- a/packages/boost/resources/boost/docs/sortable/api-reference.md +++ b/packages/boost/resources/boost/docs/sortable/api-reference.md @@ -105,9 +105,12 @@ Handle row drag & drop. Called by Alpine.js after a drag operation completes. Up Each item: `['value' => string|int, 'order' => int]` +`order` is the row's new position on screen, not the value written. The dragged rows keep the set of order values they already held, redistributed in the new visual sequence, so a drag over a searched, filtered or paginated subset cannot move the rows it does not show — see [Reordering a narrowed list](row-sorting.md#reordering-a-narrowed-list). The positions are written verbatim only when the order column is null or constant and has nothing to redistribute. + No-op if: - The table is not reorderable - The table is not in reorder mode (`$isReordering === false`) +- A row's key falls outside the table's base query (it is dropped from the write) #### `reorderColumns(array $columnOrder): void` @@ -140,7 +143,7 @@ Returns `'wire-sortable::tables.index'` when row or column reordering is enabled #### `interceptTableRecords(): LengthAwarePaginator|Paginator|CursorPaginator|Collection|null` -In reorder mode (without `paginatedWhileReordering`): bypasses search, filters, sorting, and pagination. Returns all records ordered by the sort column ascending. +In reorder mode (without `paginatedWhileReordering`): bypasses pagination and the column sort, but keeps search and filters applied. Returns every matching record ordered by the sort column ascending. Otherwise: returns `null` to let `WithTable` handle record fetching normally. diff --git a/packages/boost/resources/boost/docs/sortable/row-sorting.md b/packages/boost/resources/boost/docs/sortable/row-sorting.md index 8b301d5d..40569cce 100644 --- a/packages/boost/resources/boost/docs/sortable/row-sorting.md +++ b/packages/boost/resources/boost/docs/sortable/row-sorting.md @@ -56,12 +56,18 @@ The Blade template uses the computed `$table` property: 3. In reorder mode: - Drag handles appear on each row - Pagination is disabled (all records are shown) - - Sorting, search, and filters are bypassed - - Rows are ordered by the sort column ascending + - The column sort is bypassed -- rows are ordered by the sort column ascending + - **Search and filters stay applied**, so the list can still be narrowed 4. User drags rows to their desired position 5. On drag end, the new order is saved to the database 6. User clicks **"Done reordering"** to exit reorder mode -7. The table returns to its normal state with pagination, sorting, and filters restored +7. The table returns to its normal state with pagination and the column sort restored + +The column sort has to give way because the sequence on screen is the sequence a +drop writes back: it can only ever be the order column's. Search and filters do +not, because they change *which* rows can be dragged, not what dragging means -- +see [Reordering a narrowed list](#reordering-a-narrowed-list) for why that is +safe. ## Custom order column @@ -94,7 +100,7 @@ return $table ->columns([...]); ``` -In this mode the table is always in reorder mode -- no toggle button is rendered and `$isReordering` is set to `true` on mount. +In this mode the table is always in reorder mode -- no toggle button is rendered and `$isReordering` is set to `true` on mount. There is no way back to a plain table, which is exactly why reorder mode keeps search and filters working: the search box on an always-reorderable table would otherwise never do anything. ## Conditional reordering @@ -121,7 +127,36 @@ return $table ->columns([...]); ``` -> **Note:** With pagination enabled, users can only reorder within the current page. +> **Note:** With pagination enabled, users can only reorder within the current page. A drop rearranges that page's rows among themselves and leaves every other page where it was. + +## Reordering a narrowed list + +A user in reorder mode can still search, filter and -- with +`paginatedWhileReordering()` -- page. So a drag usually happens over a *subset* +of the table, and the rows that subset hides must not move. + +They do not, because a drop does not number the rows it was given. It collects +the order values those rows already hold, sorts them ascending, and hands them +back out in the new visual sequence: + +```php +// Rows holding sort_order 10, 20, 30. Drag the last one to the top: +// before after +// A 10 C 10 +// B 20 A 20 +// C 30 B 30 +``` + +Three consequences worth knowing: + +- **Rows outside the drag never move.** They keep their slots, so a search for + `audit` can reorder the four matching rows without disturbing the four hundred + it hid. +- **Gaps are preserved.** An order column of `10, 20, 30` stays `10, 20, 30`. If + you leave gaps to insert into later, reordering does not close them. +- **An empty or constant order column has nothing to redistribute.** There, and + only there, the client's own positions (`1..n`) are written instead -- which is + the correct answer for a column that carried no ordering to begin with. ## Lifecycle hooks diff --git a/packages/boost/resources/boost/docs/table/advanced.md b/packages/boost/resources/boost/docs/table/advanced.md index 6f11a4e9..0f5c9be4 100644 --- a/packages/boost/resources/boost/docs/table/advanced.md +++ b/packages/boost/resources/boost/docs/table/advanced.md @@ -728,6 +728,78 @@ action still shows that action inline. The dropdown inherits the table's `sheetOnMobile()` / `mobileBreakpoint()` settings (bottom-sheet on small screens by default). +### Header Actions on a Phone + +The toolbar carries the same crowding problem one level up: the search field, +the filter trigger and the view menu already sit there, and two labelled header +buttons ("New invoice", "Import CSV") push the row into a wrap at phone width. +`collapseHeaderActionsOnMobile()` folds them into one dropdown: + +```php +$table->collapseHeaderActionsOnMobile() // one "⋮" trigger instead of the buttons +``` + +Unlike `collapseActionsOnMobile()` this needs no `stackedOnMobile()` — the +toolbar is the same toolbar at every width, so the collapse is purely a width +switch. The switch is the table's **`mobileBreakpoint()`** (`sm` by default, +i.e. below 640px), not the stacking breakpoint: + +```php +$table + ->mobileBreakpoint('md') // fold below 768px instead + ->collapseHeaderActionsOnMobile() +``` + +It folds from **2** executable header actions up — a lone button is not a crowd, +and the toolbar folds sooner than a card's row actions because it shares its row +with the search field. Tune it the same way: + +```php +->collapseHeaderActionsOnMobile(threshold: 3) // keep two buttons inline, fold from three +->collapseHeaderActionsOnMobile(threshold: 1) // always fold +``` + +Only actions the viewer may run are counted, so a table whose second action is +gated by an authorization guard keeps the first one as a plain button. The +dropdown is the canonical `ActionGroup` — it inherits `sheetOnMobile()` / +`mobileBreakpoint()`, so it opens as a bottom sheet on a phone by default, and +it collapses to a single inline button when only one action survives its guards. + +Both halves sit in the document at every width (CSS decides which is shown), so +the folded copy renders **without** each action's `keyboardShortcut()`: a +rendered shortcut is a *window* listener, and a second binding would run the +action twice on one keypress. The visible desktop button keeps it. + +```php +class ListInvoices extends Component +{ + use WithTable; + + public function table(Table $table): Table + { + return $table + ->model(Invoice::class) + ->columns([ + TextColumn::make('number'), + TextColumn::make('total')->money('CZK'), + ]) + ->headerActions([ + HeaderAction::make('create') // [tl! focus:start] + ->label('New invoice') + ->icon('plus') + ->keyboardShortcut('c') // desktop only — see above + ->url(route('invoices.create')), + + HeaderAction::make('import') + ->label('Import CSV') + ->icon('arrow-up-tray') + ->action(fn () => $this->importInvoices()), + ]) + ->collapseHeaderActionsOnMobile(); // [tl! focus:end] + } +} +``` + ### The Card's Anatomy A card is a record, not the column order in disguise. Five named slots carry the diff --git a/packages/boost/resources/boost/docs/table/columns/index.md b/packages/boost/resources/boost/docs/table/columns/index.md index 31d2d39b..4c6e5fa0 100644 --- a/packages/boost/resources/boost/docs/table/columns/index.md +++ b/packages/boost/resources/boost/docs/table/columns/index.md @@ -120,6 +120,11 @@ A column left as text is skipped by a comparison rather than compared lexically, so a wrong or missing declaration narrows what search understands — it never returns wrong rows. +The declaration alone switches nothing on. A searchable column that declares a +type while the table's search does not read ranges is refused when the table +renders, naming the call it is missing — the alternative is a table that comes +back empty because `10..20` was looked for as literal text. + `'code'` is the one type that is *never* inferred: it says the value is a series plus a **zero-padded** number (`8866 01`, `8866 02`), which is what makes comparing it as text correct, and only the owner knows that. It unlocks diff --git a/packages/boost/resources/boost/docs/table/overview.md b/packages/boost/resources/boost/docs/table/overview.md index a98ba579..3746c9bb 100644 --- a/packages/boost/resources/boost/docs/table/overview.md +++ b/packages/boost/resources/boost/docs/table/overview.md @@ -329,12 +329,24 @@ number. Declare the column with over directly: ```php -TextColumn::make('reference')->searchable()->searchAs('code'); +$table + ->searchable() + ->search(fn (SearchConfig $s) => $s->tokenize()->ranges()) + ->columns([ + TextColumn::make('reference')->searchable()->searchAs('code'), + ]); // User types: 8866 01..08 // SQL: reference BETWEEN '8866 01' AND '8866 08' ``` +Both halves are required: `searchAs('code')` says what the column holds, +`ranges()` is what lets a range be typed at all, and `tokenize()` is what splits +the series from the sequence. A declaration the search box cannot ask for is +refused when the table renders — with `ranges()` off, `8866 01..08` would +otherwise be looked for as literal text and the table would come back empty with +nothing on screen to explain it. + The space inside the code is also what splits the term, so `8866 01..08` arrives as the word `8866` and the range `01..08`. The range carries the word directly before it, and a code column completes both bounds with it — write the @@ -348,9 +360,11 @@ Two rules keep it honest: - **The number must be stored padded, and typed the way it is stored.** Comparing as text is only correct while the width is constant (`01 … 08` sorts alphabetically in the same order it sorts numerically; `9 … 10` does - not). Typing `1..8` against stored `01 … 08` finds nothing. A range typed - across a width boundary is completed for you — `8866 50..100` is read as - `050..100`, since a hundredth member can only exist in a three-digit series. + not). A range is compared at the width it was typed, so `1..8` against stored + `01 … 08` is `BETWEEN '8866 1' AND '8866 8'` and matches by text — it misses + the whole padded series and reaches `8866 12` instead. A range typed across a + width boundary is completed for you — `8866 50..100` is read as `050..100`, + since a hundredth member can only exist in a three-digit series. - **The series is the one word before the range.** `faktura 8866 01..08` ranges inside `8866` and requires `faktura` separately; a code containing two spaces is out of reach. @@ -530,6 +544,9 @@ be combined on the same table: // Collapse the mobile card's row actions into one dropdown group (from N actions up) ->collapseActionsOnMobile(bool $collapse = true, int $threshold = 3) + +// The same for the toolbar's header actions, below the table's mobileBreakpoint() +->collapseHeaderActionsOnMobile(bool $collapse = true, int $threshold = 2) ``` ### Empty State diff --git a/packages/boost/resources/boost/docs/troubleshooting.md b/packages/boost/resources/boost/docs/troubleshooting.md index 8890c73f..cb1b7e48 100644 --- a/packages/boost/resources/boost/docs/troubleshooting.md +++ b/packages/boost/resources/boost/docs/troubleshooting.md @@ -134,6 +134,59 @@ See [Getting Started → JavaScript Assets](getting-started.md#javascript-assets --- +## JavaScript 404s and `wireX is not defined` + +**Symptom:** The same `ReferenceError` as the previous entry, but on *every* page +and however you reached it — a hard reload shows it too. The network tab has 404s on +`/wire-core/assets/dropdown.js`, `/wire-table/assets/records.js`, or a sibling +under `/wire-forms/…` or `/wire-sortable/…`. + +**Cause:** Two things went wrong together. The packages normally copy their bundles +into `public/vendor/` and emit *those* paths, so nothing hits PHP — a +`/wire-core/assets/…` URL in your markup means that copy could not be made and the +package route is standing in for it. And your web server is answering the route +itself instead of forwarding it to PHP. The stock Laravel nginx config sends +anything not on disk to `index.php`, but a config with a static-asset block does not: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404, PHP never sees it +} +``` + +Nothing here is package-specific: the same block 404s Livewire's own +`/livewire/livewire.js`. + +**Fix — make `public/` writable, or write it at build time.** The usual cause is a +`public/` the web user cannot write to, or a read-only container. Either grant the +write, or do the copy while the filesystem still is writable: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +**Or make the route reachable**, by letting the block fall through to the front +controller — the right answer where a writable `public/` is genuinely not on offer: + +```nginx +location ~* \.(js|css)$ { + try_files $uri /index.php?$query_string; // [tl! focus] +} +``` + +A related warning, when copies exist but could not be refreshed after an upgrade: + +```text +wireStack: the published copies of wire-core/dropdown are older than the bundles +the packages ship, and are what this page just loaded. +``` + +The page still works — an old bundle beats no bundle — but the same writability +problem is behind it. See +[Getting Started → JavaScript Assets](getting-started.md#javascript-assets). + +--- + ## Reordering stops working, or my own code loses `window.Sortable` **Symptom:** After upgrading, your application's own JavaScript throws diff --git a/packages/boost/resources/boost/docs/upgrade.md b/packages/boost/resources/boost/docs/upgrade.md index 05f5bbbb..0c344dfb 100644 --- a/packages/boost/resources/boost/docs/upgrade.md +++ b/packages/boost/resources/boost/docs/upgrade.md @@ -35,14 +35,40 @@ the changelog before bumping: | Dependency | Supported | |------------|-----------| | PHP | 8.2, 8.3, 8.4 | -| Laravel | 10, 11, 12 | +| Laravel | 12.61+, 13.12+ | | Livewire | 3.x | | Tailwind CSS | 3.x or 4.x | +| `nyoncode/laravel-package-toolkit` | ^2.4 | Confirm your app meets these before upgrading. --- +## Dependency floors (1.17) + +**Laravel 10 and 11 are gone.** 1.17 moved the JavaScript bundles from a package +route to real files under `public/vendor`, and the code that mirrors them lives in +`nyoncode/laravel-package-toolkit` — next to the `hasAssets()` declaration and the +publish tag it is the read side of. The toolkit is on `illuminate/support ^12.61.1|^13.12.0`, +and a dependency's floor is your floor: an app below it cannot resolve the Wire +packages, whatever the `^12.0` in their own `composer.json` says. Upgrade Laravel +first, then Wire. + +**The toolkit constraint is `^2.4`.** You do not require it directly, so in the +normal case `composer update "nyoncode/wire-*"` moves it with everything else and +there is nothing to do. It only becomes visible in two shapes: + +- your `composer.json` names `nyoncode/laravel-package-toolkit` — from building + your own package on it, or from an old pin — and holds it below 2.4. Composer + reports the Wire packages as uninstallable rather than the toolkit as too old, + so widen that constraint to `^2.4` first. +- you run Octane. The per-worker asset memo is flushed on `RequestTerminated` + through the toolkit's `PublishedAssets::flush()`, which 2.4 is the first release + to carry. Below it, a worker that survives a deploy keeps emitting the previous + release's `?id=` and `wire:navigate` never notices the new bundles. + +--- + ## Upgrade Steps 1. **Read the changelog.** Check `CHANGELOG.md` for the versions you are crossing, diff --git a/packages/boost/resources/boost/guidelines/wire-core.blade.php b/packages/boost/resources/boost/guidelines/wire-core.blade.php index 2299e79d..584bed0c 100644 --- a/packages/boost/resources/boost/guidelines/wire-core.blade.php +++ b/packages/boost/resources/boost/guidelines/wire-core.blade.php @@ -174,20 +174,28 @@ cached Back/Forward path, where Livewire does not wait for newly injected head scripts before initialising Alpine. -A package registers its own bundles from its own service provider's `bootedPackage()`; core never -learns about downstream packages: +The tag itself is `nyoncode/laravel-package-toolkit`'s (`PackageAssets`); `@@wireStackScripts` is a +thin alias for its `@@packageAssets`, kept because it is already in consuming layouts. A package +declares its own bundles in its own `configure()`; core never learns about downstream packages: ```php -app(AssetManager::class)->register([ - Js::make('records', self::ASSETS_PATH.'/wire-table-records.js')->navigateTrack(), -], 'wire-table'); +$packager + ->hasAssets('dist', entries: [ + Bundle::make('wire-table-records.js'), + ]) + ->hasAssetFallback(Bundle::servedByRoute('wire-table')); ``` -`Js::make($id, $path)` takes a *filesystem* path (used for the `?id=` cache-buster) and -resolves its URL from the `{package}.asset` named route each package already registers — assets are -served straight out of `dist/`, so there is no `vendor:publish` and no build step for consumers. A -path starting `http://`/`https://`/`//` is treated as remote and used verbatim. Fluent modifiers: -`module()`, `defer()`, `navigateTrack()`, `navigateOnce()`, `loadedOnRequest()`. +`Bundle` (core) is the one place that knows what shape a wireStack bundle is: `classic()`, because +every bundle is an esbuild IIFE and the toolkit would otherwise emit `type="module"` — a module is +deferred and its top-level declarations never reach `window`, so the registrar below would register +nothing and every `x-data` would die with no error at the point of the mistake. It also removes the +`defer` that `classic()` adds by default, and adds `data-navigate-once`. + +Entries are keyed by the **shipped filename**, not a short id. Delivery is the toolkit's: the mirror +copies `dist/` into `public/vendor/{package}` on first resolve — no `vendor:publish`, no build step +for consumers — and `hasAssetFallback()` points at the package's own `{package}.asset` route for the +app whose `public/` cannot be written. Without that fallback the renderer drops the tag silently. **Register Alpine components unconditionally, never only inside `alpine:init`.** That event fires exactly once per document, so a bundle arriving later (SPA navigation, a lazily rendered table, an @@ -210,8 +218,8 @@ both emit the same `src`, so the bundle may execute twice. Core interaction controllers are **never** lazy per-component — that is what causes the bug above. -Lazy is for heavy, optional bodies only (TipTap is registered `loadedOnRequest()` and excluded from -the always-loaded set). Lazy-load bodies, never registrators. +Lazy is for heavy, optional bodies only: TipTap is the one case, and it stays outside the entry list +entirely, delivered by the field that needs it. Lazy-load bodies, never registrators. ### Browser-testing hooks diff --git a/packages/boost/resources/boost/guidelines/wire-forms.blade.php b/packages/boost/resources/boost/guidelines/wire-forms.blade.php index 882c88ec..dc6f6a21 100644 --- a/packages/boost/resources/boost/guidelines/wire-forms.blade.php +++ b/packages/boost/resources/boost/guidelines/wire-forms.blade.php @@ -127,7 +127,19 @@ public function form(Form $form): Form `getOptionLabelUsing()`, `preload()`) and create/edit-option modals (`createOptionForm()` + `createOptionUsing()`, `editOptionForm()` + `fillEditOptionUsing()`/`updateOptionUsing()`) — both work in standalone forms and inside table action modals, and a created/edited option is -selected and merged into the open combobox immediately (no page refresh). The combobox honours +selected and merged into the open combobox immediately (no page refresh). The option schema is +a full form schema, not a field list: a `Wizard` inside `createOptionForm()` gates its steps +(errors land on `createOptionFormData.*`), a nested `Select` reaches remote search, and field +actions resolve — the mounted option form is enumerated as a host form. Give that wizard +`->navigation(false)` and the modal footer drives it (Back/Next beside Cancel, submit only on the +last step); name it, since footer and wizard pair up by name. Opening an option modal +from inside an option form is refused (one mounted path per kind). Both option modals are +configured through the canonical `Modals\Modal` config object — +`->createOptionModal(fn (Modal $modal) => $modal->heading(…)->description(…)->icon(…)->width('2xl') +->closeOnClickAway(false)->stickyFooter()->submitLabel(…)->cancelLabel(…))`, and the same for +`->editOptionModal()`; `createOptionModalHeading()`/`createOptionModalWidth()` (+ `editOption…` +twins) are shorthands writing into that same object. The modal's id/wire:model are not +configurable — they key the teleport Livewire morphs by. The combobox honours `->live()` — add it when siblings react to the selection. `BelongsToSelect::searchable()` without `preload()` searches the related table on the server automatically (title-attribute `like`, limit 50); `preload()` ships the full option list and filters client-side. diff --git a/packages/boost/resources/boost/guidelines/wire-sortable.blade.php b/packages/boost/resources/boost/guidelines/wire-sortable.blade.php index 5e9f0346..0fe5c282 100644 --- a/packages/boost/resources/boost/guidelines/wire-sortable.blade.php +++ b/packages/boost/resources/boost/guidelines/wire-sortable.blade.php @@ -27,7 +27,7 @@ public function table(Table $table): Table ### JavaScript assets `wireSortable` ships as a package bundle (`dist/wire-sortable.js`) with **SortableJS compiled in**, -served by the `wire-sortable.asset` route and registered with core's `AssetManager` — so reordering +declared as a toolkit asset entry with the `wire-sortable.asset` route behind it — so reordering needs no npm install, no `vendor:publish` and no CDN request, and works offline and under a strict CSP. `resources/views/partials/scripts.blade.php` is now only a thin `@@assets` wrapper (script tag + the `.wire-sortable-*` drag CSS); the Alpine component is not in the Blade any more. diff --git a/packages/boost/resources/boost/guidelines/wire-table.blade.php b/packages/boost/resources/boost/guidelines/wire-table.blade.php index 04089d77..325817d4 100644 --- a/packages/boost/resources/boost/guidelines/wire-table.blade.php +++ b/packages/boost/resources/boost/guidelines/wire-table.blade.php @@ -99,8 +99,10 @@ public function table(Table $table): Table splits the term, so the range carries the word typed before it and a code column completes both bounds with it (write the series once); any other column ignores that word and reads `01..08` as the plain range, so `praha 10..20` still works on the same table. The number must be stored padded and typed as stored -(`1..8` against stored `01 … 08` finds nothing); a range crossing a width boundary is completed — -`8866 50..100` reads as `050..100`. +(`1..8` against stored `01 … 08` compares at the typed width and misses the padded series); a range +crossing a width boundary is completed — `8866 50..100` reads as `050..100`. `searchAs()` switches +nothing on by itself: a searchable column declaring a type while the table's search does not read +ranges is refused when the table renders, naming the missing `->search(...)` call. `tokenize()` is what makes a first name in one column and a surname in another match together. `ranges()` only asks a column that can answer — the value type comes from the model's casts, or @@ -249,7 +251,7 @@ class pins `query()` to the owner record's relationship, so a subclass cannot wi - **Selection gestures (mouse + keyboard), one shared Alpine component.** Every selection surface — the checkboxes, both select-all toggles, the bulk bar, the mobile cards, the keyboard — drives one `wireRecordSelection` component reached via `[data-selection-root]` (optimistic; no per-keystroke roundtrip). Mouse: the **whole selection cell** toggles (`[data-select-cell]`, not just the 16px box, and the cell is registered interactive so the click never reaches a record action), Shift+click ranges from the anchor, mod+click toggles one row anywhere on it, mod+Shift+click adds a block, and **dragging down the checkbox column sweeps** rows in (additive only, mouse only, engages on the first row-changing move so a plain click stays a click). Keyboard: Space toggles + anchors, Shift+↑/↓ ranges, Shift+Home/End and mod+Shift+↑/↓ range to the edge, Home/End/PageUp/PageDown navigate, mod+A selects the page. **A range writes `base ∪ range`, not the range alone** — the snapshot minus the contiguous block around the anchor, so rows selected elsewhere survive; a range gesture **never rewrites `mode`**, which means in "all matching" mode (where the stored list is the *exclusions*) a range **deselects** and mod+A stands down. The anchor is one-shot and invisible; with no anchor of its own the range grows from the far edge of the block the active row sits in. Drop single rows with Space or mod+click, not a range. Keys reach the grid only when a **row itself** has the focus. **The grid reserves the keys it navigates with** — `->onKey()` on Enter/Space/arrows/Home/End/PageUp/PageDown/ContextMenu/F10/`?` throws a `TableConfigurationException` at configuration time rather than dropping the binding silently (a `keyboardShortcut()` on the action itself is only skipped); `Backspace` is deliberately not reserved and acts as a JS-side alias of `Delete`. ARIA: `aria-rowcount`, `aria-multiselectable`, `aria-rowindex` counted through the **whole result set** (not the page), bound `aria-selected` per row, and a polite live region that is in the DOM from the first paint and empty until the first change. The active-row marker is a tint **plus** a leading stripe (`activeRowClass()` replaces both halves) — the tint alone is ~1.1:1, under the 3:1 non-text contrast floor. `Table::shortcutLegend()` returns the same gesture list as data (`ShortcutHint` value objects) for rendering elsewhere. - **Empty-state actions — the way out of an empty table.** `Table::emptyStateActions([...])` renders actions inside the empty state (`->emptyState()` only sets heading/description/icon), typically "create the first record". It accepts a row `Action` **or** a `HeaderAction`, and the empty state is a **record-less** surface: both kinds execute through the header-action host methods (`executeHeaderAction` / `openHeaderActionModal`), never the row pipeline, so `->form()`, `->requiresConfirmation()` and the modal stack all work unchanged while `findHeaderAction()` searches both surfaces. Three rules follow from having no record: only a **static** `->url('/posts/create')` resolves (a per-record `->url(fn ($record) => …)` closure stays unresolved and the action renders as a plain button); an empty-state action needs a **name of its own**, because sharing one with a header action renders both when the table is empty (duplicate `data-testid`, and a `keyboardShortcut()` window listener bound twice); and they are **not** shown when a *filter* emptied the table — that state keeps offering the filter reset, since the records exist behind the filter. Under `stackedOnMobile()` the card empty state renders the same actions from a shortcut-stripped copy, so a browser-test selector matches twice (one per layout) and only one binds the shortcut. - `Table::rowContextMenu([...actions])` is **deprecated** (removed in v2.0) — a thin alias that still feeds the same context menu. Prefer `recordAction(Action::make('edit')->onContextMenu())`. -- **Mobile (`Table::stackedOnMobile()`).** Below the breakpoint each row becomes a card whose hierarchy is five derived slots — title (first column), metric (last right-aligned, e.g. `money()`), meta (badge columns), subtitle, and a label/value grid for the rest — overridable per column (`->mobileMetric()`, `->mobileMeta()`, …) or per table (`->mobileCard(fn (MobileCardConfig $c) => $c->title('number')->metric('total'))`). The header row is hidden, so its controls move into the card view: an always-visible select-all strip, a sort control, sub-row children with their subtotal, the summary totals, and the **empty state** — which is the same canonical surface as the desktop one, so a custom icon/description, the filter-empty reset and `emptyStateActions()` all render on a phone. `->collapseActionsOnMobile()` folds row actions into one dropdown. **Record actions fall back to buttons here**: every record trigger is a desktop one, so a behaviour-only record action renders as an ordinary button on the card (and only there) — one declaration, a gesture on the desktop and a button on a phone. It never doubles an action already in `->actions()`, one referenced by name, or one promoted with `->alsoInRowActions()`, and the fallback buttons count towards `collapseActionsOnMobile()`; the card's copy drops the action's `keyboardShortcut()` (`HasKeyboardShortcut::withoutKeyboardShortcut()`), because a rendered button binds its shortcut as a *window* listener and the cards are in the document at every width. Opt out with `Table::recordActionButtonsOnMobile(false)`. +- **Mobile (`Table::stackedOnMobile()`).** Below the breakpoint each row becomes a card whose hierarchy is five derived slots — title (first column), metric (last right-aligned, e.g. `money()`), meta (badge columns), subtitle, and a label/value grid for the rest — overridable per column (`->mobileMetric()`, `->mobileMeta()`, …) or per table (`->mobileCard(fn (MobileCardConfig $c) => $c->title('number')->metric('total'))`). The header row is hidden, so its controls move into the card view: an always-visible select-all strip, a sort control, sub-row children with their subtotal, the summary totals, and the **empty state** — which is the same canonical surface as the desktop one, so a custom icon/description, the filter-empty reset and `emptyStateActions()` all render on a phone. `->collapseActionsOnMobile()` folds row actions into one dropdown. **Record actions fall back to buttons here**: every record trigger is a desktop one, so a behaviour-only record action renders as an ordinary button on the card (and only there) — one declaration, a gesture on the desktop and a button on a phone. It never doubles an action already in `->actions()`, one referenced by name, or one promoted with `->alsoInRowActions()`, and the fallback buttons count towards `collapseActionsOnMobile()`; the card's copy drops the action's `keyboardShortcut()` (`HasKeyboardShortcut::withoutKeyboardShortcut()`), because a rendered button binds its shortcut as a *window* listener and the cards are in the document at every width. Opt out with `Table::recordActionButtonsOnMobile(false)`. **The toolbar folds too, separately**: `Table::collapseHeaderActionsOnMobile(bool $collapse = true, int $threshold = 2)` collapses the *header* actions into one `ActionGroup` dropdown — no `stackedOnMobile()` needed (the toolbar is the same at every width), switched on the table's `mobileBreakpoint()` (`sm` by default) rather than the stacking breakpoint, counting only actions the viewer may run, and rendering the folded copy shortcut-less for the same window-listener reason. - `Table::queryString()` persists state to the URL. - Browser-testing hooks: every active part carries a stable `data-testid` — `table-search`, `table-filters-trigger`, `table-filter-reset`, `filter-chip-{name}`, `column-filter-chip-{name}`, `table-column-toggle`, `table-per-page`, `table-page-prev|next|{n}`, `table-sort-{col}`, `table-filter-{col}`, `table-cell-{col}`, `table-editable-{col}`, `table-row` (+ `data-row-key`; mobile `table-card`), `table-select-all` / `table-row-select`, `table-row-expand`, `table-bulk-bar` / `table-deselect`, and `action-{name}` / `header-action-{name}` / `bulk-action-{name}` / `menu-action-{name}` (all with `aria-label`) — so Pest v4 Browser Testing targets them at the user level. An **empty-state action reuses the testid of its kind**, and under `stackedOnMobile()` it matches twice (desktop table + card layout), so select the visible one. Actions and filter options are also reachable by visible text. Column-static render metadata is resolved once per column (`$columnMeta`) instead of per cell. diff --git a/packages/core/composer.json b/packages/core/composer.json index fc5fa436..037e2322 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -11,14 +11,14 @@ ], "require": { "php": "^8.2", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", "livewire/livewire": "^3.0", - "nyoncode/laravel-package-toolkit": "^2.0.1" + "nyoncode/laravel-package-toolkit": "^2.4.2" }, "require-dev": { - "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "orchestra/testbench": "^10.0|^11.0", "laravel/pint": "^1.29", - "pestphp/pest": "^2.0|^3.0|^4.0" + "pestphp/pest": "^4.0|^5.0" }, "autoload": { "psr-4": { diff --git a/packages/core/dist/wire-core-copy.js b/packages/core/dist/wire-core-copy.js new file mode 100644 index 00000000..2a587d95 --- /dev/null +++ b/packages/core/dist/wire-core-copy.js @@ -0,0 +1 @@ +(()=>{var o=null;async function n(e){if(!navigator.clipboard?.writeText)return!1;try{return await navigator.clipboard.writeText(e),!0}catch{return!1}}function a(e){let t=document.querySelector("[data-copy-feedback]");if(!t)return;let r=t.querySelector("[data-copy-feedback-text]");r&&(r.textContent=e.getAttribute("data-copy-message")??"");let i=e.getBoundingClientRect();t.style.left=`${i.right+6}px`,t.style.top=`${i.top+i.height/2}px`,t.hidden=!1,clearTimeout(o),o=setTimeout(()=>{t.hidden=!0},2e3)}function c(e){let t=e.target.closest("[data-copy]");t&&(e.preventDefault(),n(t.getAttribute("data-copy")??"").then(r=>{r&&a(t)}))}window.wireCoreCopyInstalled||(window.wireCoreCopyInstalled=!0,document.addEventListener("click",c));})(); diff --git a/packages/core/resources/js/copy.js b/packages/core/resources/js/copy.js new file mode 100644 index 00000000..07b94478 --- /dev/null +++ b/packages/core/resources/js/copy.js @@ -0,0 +1,100 @@ +/* + * wireTableCopy — one delegated clipboard controller for the whole document. + * + * The copy affordance used to be an Alpine component per cell: an `x-data`, an + * inline multi-line `x-on:click`, two `