diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 1862859..cb8d43c 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -68,18 +68,22 @@ jobs: set -e ls -la test -f index.html || { echo "ERROR: index.html not found"; exit 1; } + test -f docs/index.html || { echo "ERROR: docs page not found"; exit 1; } test -f main.client.dart.js || { echo "ERROR: client bundle not found"; exit 1; } echo "Build output OK" # The project Pages site lives at the /dnd_kit/ subpath. jaspr build has no # --base-href flag and Document emits , so rewrite it here - # (asset URLs in index.html are relative and resolve against this base). + # on every generated page (relative asset and cross-page URLs resolve + # against this base). - name: Set base href for the project subpath working-directory: website/build/jaspr run: | set -e - sed -i 's|||' index.html + find . -name '*.html' -exec \ + sed -i 's|||' {} + grep -q '' index.html + grep -q '' docs/index.html touch .nojekyll - name: Upload Pages artifact diff --git a/README.md b/README.md index 3af179d..d8e2b4e 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,11 @@ workspace unification through `US-061`, and the Jaspr sortable preset through axis-aware shared-core implementation slice, and the Flutter execution-layer adoption through `US-065`, plus the Jaspr execution-layer adoption through `US-066`, plus the Jaspr example feature gallery through `US-067`, plus the -Jaspr drag overlay controller rebind fix through `US-068`, and the shared -experimental multi-container parity slice through `US-076`. +Jaspr drag overlay controller rebind fix through `US-068`, the shared +experimental multi-container parity slice through `US-076`, the +production-ready multi-container graduation and website showcase through +`US-078`, and the coordinated family stable `0.4.0` publication (`dnd_kit`, +`dnd_kit_flutter`, `dnd_kit_jaspr`) through `US-079`. The living source of truth is split from historical [SPEC.md](SPEC.md) input material into product docs, story packets, validation expectations, and decision diff --git a/docs/product/release-roadmap.md b/docs/product/release-roadmap.md index 98f5b2a..1159f81 100644 --- a/docs/product/release-roadmap.md +++ b/docs/product/release-roadmap.md @@ -317,6 +317,24 @@ publication after the multi-container graduation work has soaked: Phase README: `docs/stories/phase-31-coordinated-family-stable-0-4-0-release/README.md`. +## Phase 32 - Website Docs Page And Post-0.4.0 Doc Alignment + +Close the docs/adoption gap left after the stable `0.4.0` publication: + +- `US-080`: give the website a real Getting Started docs page on its own `/docs` + route (replacing the in-page `#docs` placeholder) and align the root README + status plus the Jaspr/Flutter package READMEs with the shipped stable `0.4.0` + family; +- `US-081`: expand `/docs` into a multi-page docs section (shared sidebar shell, + right-rail TOC, prev/next pager, Flutter|Jaspr code tabs) covering the nine + core pages; +- `US-082`: complete the coverage with four more Concepts pages (collision, + sensors, modifiers, auto-scroll), an API Reference page, and a collapsible + mobile docs menu. + +Phase README: +`docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md`. + ## Current State The repository has implemented work through `US-079`. The Flutter adapter, the @@ -360,6 +378,16 @@ packet for that line: the prepared `0.4.0-dev.1` family metadata was promoted to stable `0.4.0`, local release proof passed through the shared verifier, and the three packages published to pub.dev in dependency order on 2026-06-24: `dnd_kit 0.4.0` -> `dnd_kit_flutter 0.4.0` -> `dnd_kit_jaspr 0.4.0`. +Phase 32 then closed the post-release docs/adoption gap: `US-080` gave the +website a real Getting Started docs page on its own statically generated `/docs` +route (replacing the `#docs` placeholder) and aligned the root README plus the +Jaspr and Flutter package READMEs with the shipped stable `0.4.0` family, and +`US-081` expanded `/docs` into a multi-page docs section — a shared `DocsShell` +(grouped sidebar, right-rail TOC, prev/next pager), a Flutter|Jaspr code-tab +block, and nine core pages over the shared engine surface, and `US-082` +completed the coverage with four more Concepts pages (collision, sensors, +modifiers, auto-scroll), an API Reference page, and a collapsible mobile docs +menu. Future work should keep extending this roadmap through new product docs, story packets, and decisions rather than by reviving the old umbrella/core topology from the historical specs. diff --git a/docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md b/docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md new file mode 100644 index 0000000..c35d108 --- /dev/null +++ b/docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md @@ -0,0 +1,54 @@ +# Phase 32 - Website Docs Page And Post-0.4.0 Doc Alignment + +The coordinated family stable `0.4.0` published on 2026-06-24 (Phase 31), but +public-facing docs still described the pre-release state: the website had only a +`#docs` placeholder, the root README status stopped at `US-076`, and the Jaspr +and Flutter package READMEs still carried "dev release" wording. + +This phase closes that adoption gap across two normal-lane stories: + +- `US-080` gave the website a real Getting Started docs page on its own `/docs` + route (the marketing site was previously a single page with an in-page + `#docs` anchor placeholder) and aligned the root README plus the Jaspr and + Flutter package READMEs with the shipped stable `0.4.0` family; +- `US-081` then expanded `/docs` from that single page into a dndkit.com-style + multi-page docs section: a shared `DocsShell` (grouped sidebar, right-rail + "On this page" TOC, previous/next pager), a Flutter|Jaspr `CodeTabs` block, + and nine core pages (Overview, Installation, Quickstart, Draggable, + Droppable, Drag overlay, Sortable lists, Multi-container, Accessibility); +- `US-082` completed the docs coverage: four more Concepts pages (Collision + detection, Sensors & activation, Modifiers, Auto-scroll) written against the + real engine API, an API Reference page (dartdoc + changelog links), and a + collapsible mobile docs menu so the section is navigable below the `lg` + breakpoint. + +## Principle + +Doc-alignment and website work in this phase must: + +- introduce real on-site routing rather than another in-page anchor: the docs + page is a separate statically generated route (`docs/index.html`), reached + from the nav and footer; +- keep the static build deployable under the `/dnd_kit/` project Pages subpath — + every generated page needs its `` rewritten, and cross-page links + resolve relative to that base; +- describe only the already-shipped `0.4.0` surface; no new product behavior. + +## Delivery Sequence + +| Story | Scope | Decision | +| --- | --- | --- | +| **US-080** | Add the website `/docs` Getting Started page (jaspr_router) and align root/package READMEs + website docs link with stable `0.4.0` | No ADR (docs/adoption work under the existing website and package-topology decisions) | +| **US-081** | Expand `/docs` into a multi-page docs section: shared `DocsShell` (sidebar + TOC + pager), Flutter\|Jaspr `CodeTabs`, nine core pages | No ADR (unified single docs tree with per-snippet adapter tabs, under the existing website decisions) | +| **US-082** | Complete docs coverage: four Concepts pages (collision, sensors, modifiers, auto-scroll), an API Reference page, and a mobile docs menu | No ADR (docs content + responsive nav under the existing website decisions) | + +## Validation Ladder + +- Static-site proof: `tool/styles.sh --minify` plus + `dart pub global run jaspr_cli:jaspr build` generate both the `/` and `/docs` + routes, and `website/build/jaspr/docs/index.html` is present with the + per-route `` and relative asset URLs. +- Analyzer/format proof: `fvm dart analyze` and `fvm dart format` stay clean for + the `website` package. +- Deploy proof: the deploy workflow rewrites `<base href>` on every generated + HTML file and verifies both `index.html` and `docs/index.html`. diff --git a/packages/dnd_kit_flutter/README.md b/packages/dnd_kit_flutter/README.md index abf5273..4d099c6 100644 --- a/packages/dnd_kit_flutter/README.md +++ b/packages/dnd_kit_flutter/README.md @@ -154,5 +154,5 @@ drag runtime. | Package | Use it for | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | [`dnd_kit_flutter`](https://pub.dev/packages/dnd_kit_flutter) | Flutter apps — widgets, sensors, overlays, and sortable presets. | -| [`dnd_kit_jaspr`](https://pub.dev/packages/dnd_kit_jaspr) | Jaspr (Dart web) apps — the current dev adapter release. | +| [`dnd_kit_jaspr`](https://pub.dev/packages/dnd_kit_jaspr) | Jaspr (Dart web) apps — the web adapter over the shared engine. | | [`dnd_kit`](https://pub.dev/packages/dnd_kit) | The shared, framework-agnostic engine. | diff --git a/packages/dnd_kit_jaspr/README.md b/packages/dnd_kit_jaspr/README.md index 0344cab..252f887 100644 --- a/packages/dnd_kit_jaspr/README.md +++ b/packages/dnd_kit_jaspr/README.md @@ -8,7 +8,7 @@ Flutter behave as peer adapters over one drag runtime — the same domain model, drag lifecycle, collision logic, modifiers, sortable math, and auto-scroll curve. It depends only on `dnd_kit` and `jaspr` (no Flutter). -> Status: current public development release. This package provides `DndScope`, +> Status: stable `0.4.0` release. This package provides `DndScope`, > `DndController`, `DndDraggable`, `DndDroppable`, `DndDragHandle`, > `DndDragOverlay`, `DndAutoScroll`, `DndLiveRegion`, `SortableScope`, and > `SortableItem` over the shared runtime. Shared modifiers and sortable diff --git a/pubspec.lock b/pubspec.lock index dec55b3..92a7d6e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -371,6 +371,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.23.1" + jaspr_router: + dependency: transitive + description: + name: jaspr_router + sha256: "43abdfd63c19cae8faac50334c7f3065635bac3e85fd558b6076517978ade64a" + url: "https://pub.dev" + source: hosted + version: "0.8.2" jaspr_test: dependency: transitive description: diff --git a/website/README.md b/website/README.md index 037f6c8..ff91aa8 100644 --- a/website/README.md +++ b/website/README.md @@ -60,4 +60,6 @@ static host (GitHub Pages, Netlify, Cloudflare Pages, …). - GitHub: https://github.com/iamv4g/dnd_kit - pub.dev: https://pub.dev/packages/dnd_kit_jaspr -- Docs: _coming soon_ (currently a `#docs` placeholder in the nav/footer) +- Docs: the multi-page documentation section under the `/docs` routes + (`lib/docs/`), linked from the nav and footer. `lib/docs/docs_nav.dart` is the + single source of truth for the sidebar, pager, and routes. diff --git a/website/lib/app.dart b/website/lib/app.dart new file mode 100644 index 0000000..91f261c --- /dev/null +++ b/website/lib/app.dart @@ -0,0 +1,104 @@ +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_router/jaspr_router.dart'; + +import 'docs/pages/accessibility_page.dart'; +import 'docs/pages/autoscroll_page.dart'; +import 'docs/pages/collision_page.dart'; +import 'docs/pages/draggable_page.dart'; +import 'docs/pages/droppable_page.dart'; +import 'docs/pages/install_page.dart'; +import 'docs/pages/modifiers_page.dart'; +import 'docs/pages/multi_container_page.dart'; +import 'docs/pages/overlay_page.dart'; +import 'docs/pages/overview_page.dart'; +import 'docs/pages/quickstart_page.dart'; +import 'docs/pages/reference_page.dart'; +import 'docs/pages/sensors_page.dart'; +import 'docs/pages/sortable_page.dart'; +import 'site.dart'; + +/// Top-level routing. In static (SSG) mode jaspr generates one HTML file per +/// route, so this emits `index.html` (the marketing home) plus a page under +/// `docs/` for each documentation route. +class App extends StatelessComponent { + const App({super.key}); + + @override + Component build(BuildContext context) { + return Router( + routes: [ + Route(path: '/', builder: (context, state) => const Site()), + Route( + path: '/docs', + title: 'Documentation · dnd_kit', + builder: (context, state) => const OverviewPage(), + ), + Route( + path: '/docs/install', + title: 'Installation · dnd_kit', + builder: (context, state) => const InstallPage(), + ), + Route( + path: '/docs/quickstart', + title: 'Quickstart · dnd_kit', + builder: (context, state) => const QuickstartPage(), + ), + Route( + path: '/docs/draggable', + title: 'Draggable · dnd_kit', + builder: (context, state) => const DraggablePage(), + ), + Route( + path: '/docs/droppable', + title: 'Droppable · dnd_kit', + builder: (context, state) => const DroppablePage(), + ), + Route( + path: '/docs/overlay', + title: 'Drag overlay · dnd_kit', + builder: (context, state) => const OverlayPage(), + ), + Route( + path: '/docs/collision', + title: 'Collision detection · dnd_kit', + builder: (context, state) => const CollisionPage(), + ), + Route( + path: '/docs/sensors', + title: 'Sensors & activation · dnd_kit', + builder: (context, state) => const SensorsPage(), + ), + Route( + path: '/docs/modifiers', + title: 'Modifiers · dnd_kit', + builder: (context, state) => const ModifiersPage(), + ), + Route( + path: '/docs/auto-scroll', + title: 'Auto-scroll · dnd_kit', + builder: (context, state) => const AutoscrollPage(), + ), + Route( + path: '/docs/sortable', + title: 'Sortable lists · dnd_kit', + builder: (context, state) => const SortablePage(), + ), + Route( + path: '/docs/multi-container', + title: 'Multi-container sortable · dnd_kit', + builder: (context, state) => const MultiContainerPage(), + ), + Route( + path: '/docs/accessibility', + title: 'Accessibility · dnd_kit', + builder: (context, state) => const AccessibilityPage(), + ), + Route( + path: '/docs/reference', + title: 'API reference · dnd_kit', + builder: (context, state) => const ReferencePage(), + ), + ], + ); + } +} diff --git a/website/lib/data/site_data.dart b/website/lib/data/site_data.dart index 8bdac38..ba22082 100644 --- a/website/lib/data/site_data.dart +++ b/website/lib/data/site_data.dart @@ -11,8 +11,10 @@ class SiteLinks { static const pubFlutter = 'https://pub.dev/packages/dnd_kit_flutter'; static const pubJaspr = 'https://pub.dev/packages/dnd_kit_jaspr'; - /// Docs site is built later; placeholder for now. - static const docs = '#docs'; + /// The on-site Getting Started docs page (`/docs` route). Written relative so + /// it resolves against the document `<base href>` on both the project Pages + /// subpath (`/dnd_kit/docs/`) and a local server root (`/docs/`). + static const docs = 'docs/'; } /// In-page nav targets (also the reorderable nav pills). diff --git a/website/lib/docs/code_tabs.dart b/website/lib/docs/code_tabs.dart new file mode 100644 index 0000000..177a279 --- /dev/null +++ b/website/lib/docs/code_tabs.dart @@ -0,0 +1,75 @@ +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +/// A code block with a Flutter / Jaspr toggle, so one snippet shows the same +/// API on both adapters. Server-rendered with the Flutter tab active, then +/// hydrated to switch tabs on the client. +@client +class CodeTabs extends StatefulComponent { + const CodeTabs({ + required this.flutter, + required this.jaspr, + this.flutterFile = 'main.dart', + this.jasprFile = 'main.dart', + super.key, + }); + + final String flutter; + final String jaspr; + final String flutterFile; + final String jasprFile; + + @override + State<CodeTabs> createState() => _CodeTabsState(); +} + +class _CodeTabsState extends State<CodeTabs> { + int _tab = 0; // 0 = Flutter, 1 = Jaspr + + @override + Component build(BuildContext context) { + final tabs = ['Flutter', 'Jaspr']; + final code = _tab == 0 ? component.flutter : component.jaspr; + final file = _tab == 0 ? component.flutterFile : component.jasprFile; + return div( + classes: + 'overflow-hidden rounded-2xl border border-line bg-surface shadow-lift', + [ + div( + classes: + 'flex items-center gap-3 border-b border-line bg-raised px-4 py-2.5', + [ + div( + classes: 'flex items-center gap-1', + attributes: const {'role': 'tablist'}, + [ + for (var i = 0; i < tabs.length; i++) + button( + classes: + 'rounded-full px-3 py-1 font-mono text-xs transition-colors ' + '${i == _tab ? 'bg-accent text-white' : 'text-muted hover:text-ink'}', + attributes: { + 'type': 'button', + 'role': 'tab', + 'aria-selected': (i == _tab).toString(), + }, + onClick: () => setState(() => _tab = i), + [.text(tabs[i])], + ), + ], + ), + span(classes: 'ml-auto font-mono text-xs text-muted', [ + .text(file), + ]), + ], + ), + Component.element( + tag: 'pre', + classes: + 'overflow-x-auto p-5 font-mono text-sm leading-relaxed text-ink', + children: [.text(code)], + ), + ], + ); + } +} diff --git a/website/lib/docs/doc_components.dart b/website/lib/docs/doc_components.dart new file mode 100644 index 0000000..281e195 --- /dev/null +++ b/website/lib/docs/doc_components.dart @@ -0,0 +1,142 @@ +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +/// Shared building blocks for documentation pages: the lead paragraph, the +/// "You'll learn" callout, section wrappers, prose, bullet lists, inline code, +/// and the "Next steps" card grid. + +const _prose = 'max-w-3xl leading-relaxed text-muted'; + +/// The one-sentence summary directly under a page heading. +Component docLead(String text) { + return p(classes: 'max-w-3xl text-lg leading-relaxed text-muted', [ + .text(text), + ]); +} + +/// A short outcomes callout shown near the top of a page. +Component youWillLearn(List<String> items) { + return div( + classes: 'max-w-3xl rounded-2xl border border-line bg-surface p-5', + [ + span( + classes: 'font-mono text-xs uppercase tracking-[0.18em] text-accent', + const [.text("You'll learn")], + ), + ul(classes: 'mt-3 flex flex-col gap-1.5 text-muted', [ + for (final item in items) + li(classes: 'flex gap-2', [ + span(classes: 'text-accent', const [.text('—')]), + span([.text(item)]), + ]), + ]), + ], + ); +} + +/// A page section with an anchored `<h2>` heading. +Component docSection({ + required String id, + required String title, + required List<Component> children, +}) { + return section(id: id, classes: 'scroll-mt-24', [ + h2(classes: 'font-serif text-2xl text-ink sm:text-3xl', [.text(title)]), + div(classes: 'mt-4 flex flex-col gap-4', children), + ]); +} + +/// A prose paragraph. [spans] lets a paragraph mix text with inline code. +Component docProse(String text) => p(classes: _prose, [.text(text)]); + +/// A prose paragraph built from mixed inline content (text + inlineCode). +Component docProseRich(List<Component> spans) => p(classes: _prose, spans); + +/// Plain inline text node, for composing with [inlineCode] in [docProseRich]. +Component docText(String text) => Component.text(text); + +/// Inline `code` styling. +Component inlineCode(String text) { + return Component.element( + tag: 'code', + classes: 'rounded bg-raised px-1.5 py-0.5 font-mono text-[0.85em] text-ink', + children: [.text(text)], + ); +} + +/// A plain (non-tabbed) code block with a filename label. +Component docCodeBlock(String filename, String code) { + return div( + classes: + 'overflow-hidden rounded-2xl border border-line bg-surface shadow-lift', + [ + div( + classes: + 'flex items-center gap-3 border-b border-line bg-raised px-4 py-2.5', + [ + div(classes: 'flex items-center gap-2', [ + span(classes: 'h-2.5 w-2.5 rounded-full bg-accent/70', const []), + span(classes: 'h-2.5 w-2.5 rounded-full bg-muted/40', const []), + span(classes: 'h-2.5 w-2.5 rounded-full bg-muted/40', const []), + ]), + span(classes: 'ml-auto font-mono text-xs text-muted', [ + .text(filename), + ]), + ], + ), + Component.element( + tag: 'pre', + classes: + 'overflow-x-auto p-5 font-mono text-sm leading-relaxed text-ink', + children: [.text(code)], + ), + ], + ); +} + +/// An unordered list of prose items. +Component docBullets(List<String> items) { + return ul(classes: 'flex max-w-3xl flex-col gap-2 text-muted', [ + for (final item in items) + li(classes: 'flex gap-2', [ + span(classes: 'mt-0.5 text-accent', const [.text('•')]), + span([.text(item)]), + ]), + ]); +} + +/// A "Next steps" card linking to another doc page or external resource. +class NextStep { + const NextStep({ + required this.label, + required this.desc, + required this.href, + this.external = false, + }); + + final String label; + final String desc; + final String href; + final bool external; +} + +/// The "Next steps" card grid shown at the bottom of a page body. +Component nextSteps(List<NextStep> steps) { + return div(classes: 'mt-2 grid gap-3 sm:grid-cols-2', [ + for (final step in steps) + a( + href: step.href, + target: step.external ? Target.blank : null, + attributes: step.external ? const {'rel': 'noreferrer'} : null, + classes: + 'flex flex-col gap-1 rounded-2xl border border-line bg-surface p-4 ' + 'transition-colors hover:border-accent', + [ + span(classes: 'font-semibold text-ink', [ + .text(step.external ? '${step.label} ↗' : step.label), + ]), + span(classes: 'text-sm text-muted', [.text(step.desc)]), + ], + ), + ]); +} diff --git a/website/lib/docs/docs_nav.dart b/website/lib/docs/docs_nav.dart new file mode 100644 index 0000000..8559768 --- /dev/null +++ b/website/lib/docs/docs_nav.dart @@ -0,0 +1,178 @@ +/// The documentation information architecture: grouped sidebar entries plus a +/// flat ordered list that drives the previous/next pager. This is the single +/// source of truth for the docs section — pages, routes, and the sidebar all +/// read from it. +library; + +/// One documentation page. +class DocEntry { + const DocEntry({ + required this.slug, + required this.navLabel, + required this.title, + required this.group, + }); + + /// URL slug under `/docs`. Empty for the docs landing (`/docs`). + final String slug; + + /// Short label shown in the sidebar. + final String navLabel; + + /// Page heading (`<h1>`). + final String title; + + /// Group label, shown as the eyebrow above the heading. + final String group; + + /// jaspr_router path for this page (e.g. `/docs` or `/docs/draggable`). + String get routePath => slug.isEmpty ? '/docs' : '/docs/$slug'; + + /// Link target, relative to the document `<base href>`. Trailing slash keeps + /// same-page anchors on the generated `index.html` instead of bouncing + /// through a Pages redirect. + String get href => slug.isEmpty ? 'docs/' : 'docs/$slug/'; + + /// A same-page anchor link to a section [id] on this page. + String anchor(String id) => '$href#$id'; +} + +/// A labelled group of sidebar entries. +class DocGroup { + const DocGroup({required this.label, required this.entries}); + + final String label; + final List<DocEntry> entries; +} + +const docGroups = <DocGroup>[ + DocGroup( + label: 'Get started', + entries: [ + DocEntry( + slug: '', + navLabel: 'Overview', + title: 'dnd_kit documentation', + group: 'Get started', + ), + DocEntry( + slug: 'install', + navLabel: 'Installation', + title: 'Installation', + group: 'Get started', + ), + DocEntry( + slug: 'quickstart', + navLabel: 'Quickstart', + title: 'Quickstart', + group: 'Get started', + ), + ], + ), + DocGroup( + label: 'Concepts', + entries: [ + DocEntry( + slug: 'draggable', + navLabel: 'Draggable', + title: 'Draggable', + group: 'Concepts', + ), + DocEntry( + slug: 'droppable', + navLabel: 'Droppable', + title: 'Droppable', + group: 'Concepts', + ), + DocEntry( + slug: 'overlay', + navLabel: 'Drag overlay', + title: 'Drag overlay', + group: 'Concepts', + ), + DocEntry( + slug: 'collision', + navLabel: 'Collision detection', + title: 'Collision detection', + group: 'Concepts', + ), + DocEntry( + slug: 'sensors', + navLabel: 'Sensors & activation', + title: 'Sensors & activation', + group: 'Concepts', + ), + DocEntry( + slug: 'modifiers', + navLabel: 'Modifiers', + title: 'Modifiers', + group: 'Concepts', + ), + DocEntry( + slug: 'auto-scroll', + navLabel: 'Auto-scroll', + title: 'Auto-scroll', + group: 'Concepts', + ), + ], + ), + DocGroup( + label: 'Sortable', + entries: [ + DocEntry( + slug: 'sortable', + navLabel: 'Sortable lists', + title: 'Sortable lists', + group: 'Sortable', + ), + DocEntry( + slug: 'multi-container', + navLabel: 'Multi-container', + title: 'Multi-container sortable', + group: 'Sortable', + ), + ], + ), + DocGroup( + label: 'Accessibility', + entries: [ + DocEntry( + slug: 'accessibility', + navLabel: 'Accessibility', + title: 'Accessibility', + group: 'Accessibility', + ), + ], + ), + DocGroup( + label: 'Reference', + entries: [ + DocEntry( + slug: 'reference', + navLabel: 'API reference', + title: 'API reference', + group: 'Reference', + ), + ], + ), +]; + +/// All entries in sidebar order — drives the previous/next pager. +final List<DocEntry> docOrder = [ + for (final group in docGroups) ...group.entries, +]; + +/// The entry immediately before [slug] in reading order, or null. +DocEntry? docPrev(String slug) { + final i = docOrder.indexWhere((e) => e.slug == slug); + return i > 0 ? docOrder[i - 1] : null; +} + +/// The entry immediately after [slug] in reading order, or null. +DocEntry? docNext(String slug) { + final i = docOrder.indexWhere((e) => e.slug == slug); + return i >= 0 && i < docOrder.length - 1 ? docOrder[i + 1] : null; +} + +/// The base-relative link for a page [slug] (for cross-page references). +String docHref(String slug) => docOrder.firstWhere((e) => e.slug == slug).href; diff --git a/website/lib/docs/docs_shell.dart b/website/lib/docs/docs_shell.dart new file mode 100644 index 0000000..f35061d --- /dev/null +++ b/website/lib/docs/docs_shell.dart @@ -0,0 +1,200 @@ +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +import '../components/ui.dart'; +import '../layout/footer.dart'; +import '../layout/nav_bar.dart'; +import 'docs_nav.dart'; + +/// The shared documentation chrome: top nav, a grouped left sidebar, the page +/// body, a right-rail "On this page" table of contents, and a previous/next +/// pager. Every docs page wraps its content in this shell. +class DocsShell extends StatelessComponent { + const DocsShell({ + required this.slug, + required this.toc, + required this.body, + super.key, + }); + + /// The current page's slug (matches a [DocEntry.slug]). + final String slug; + + /// Right-rail anchors for the sections on this page. + final List<({String id, String label})> toc; + + /// The page body — lead, callout, and sections. + final List<Component> body; + + DocEntry get _entry => docOrder.firstWhere((e) => e.slug == slug); + + @override + Component build(BuildContext context) { + final entry = _entry; + return .fragment([ + div(id: 'top', const []), + const NavBar(activeDocs: true), + Component.element( + tag: 'main', + children: [ + div( + classes: + 'mx-auto max-w-7xl gap-10 px-6 py-10 ' + 'lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] ' + 'xl:grid-cols-[14rem_minmax(0,1fr)_13rem]', + [_sidebar(), _content(entry), _tocRail()], + ), + ], + ), + const Footer(), + ]); + } + + Component _sidebar() { + return Component.element( + tag: 'aside', + classes: 'hidden lg:block', + children: [ + nav( + classes: 'sticky top-24 flex flex-col gap-6', + attributes: const {'aria-label': 'Documentation'}, + [ + for (final group in docGroups) + div(classes: 'flex flex-col gap-1', [ + span( + classes: + 'mb-1 font-mono text-xs uppercase tracking-[0.18em] ' + 'text-muted', + [.text(group.label)], + ), + for (final entry in group.entries) _sidebarLink(entry), + ]), + ], + ), + ], + ); + } + + Component _sidebarLink(DocEntry entry) { + final active = entry.slug == slug; + return a( + href: entry.href, + attributes: active ? const {'aria-current': 'page'} : null, + classes: + 'rounded-lg px-3 py-1.5 text-sm transition-colors ' + '${active ? 'bg-surface font-medium text-accent' : 'text-muted hover:bg-surface hover:text-ink'}', + [.text(entry.navLabel)], + ); + } + + /// A collapsible group menu shown below the `lg` breakpoint, where the + /// fixed sidebar is hidden. Native `<details>`, so it needs no hydration. + Component _mobileNav() { + return Component.element( + tag: 'details', + classes: + 'sticky top-16 z-20 mb-8 rounded-2xl border border-line ' + 'bg-paper/95 shadow-sm backdrop-blur lg:hidden', + children: [ + Component.element( + tag: 'summary', + classes: + 'cursor-pointer select-none rounded-2xl px-4 py-3 text-sm ' + 'font-medium text-ink', + children: const [.text('Documentation menu')], + ), + div( + classes: + 'flex max-h-[70vh] flex-col gap-5 overflow-auto border-t ' + 'border-line px-4 py-4', + [ + for (final group in docGroups) + div(classes: 'flex flex-col gap-1', [ + span( + classes: + 'mb-1 font-mono text-xs uppercase tracking-[0.18em] ' + 'text-muted', + [.text(group.label)], + ), + for (final entry in group.entries) _sidebarLink(entry), + ]), + ], + ), + ], + ); + } + + Component _content(DocEntry entry) { + return div(classes: 'min-w-0', [ + _mobileNav(), + eyebrow(entry.group), + h1(classes: 'mt-3 font-serif text-4xl text-ink sm:text-5xl', [ + .text(entry.title), + ]), + div(classes: 'mt-6 flex flex-col gap-10', body), + _pager(), + ]); + } + + Component _tocRail() { + if (toc.isEmpty) return Component.element(tag: 'div', children: const []); + return Component.element( + tag: 'aside', + classes: 'hidden xl:block', + children: [ + nav( + classes: 'sticky top-24 flex flex-col gap-2', + attributes: const {'aria-label': 'On this page'}, + [ + span( + classes: + 'font-mono text-xs uppercase tracking-[0.18em] text-muted', + const [.text('On this page')], + ), + for (final item in toc) + a( + href: _entry.anchor(item.id), + classes: 'text-sm text-muted transition-colors hover:text-ink', + [.text(item.label)], + ), + ], + ), + ], + ); + } + + Component _pager() { + final prev = docPrev(slug); + final next = docNext(slug); + if (prev == null && next == null) { + return Component.element(tag: 'div', children: const []); + } + return div( + classes: + 'mt-12 flex items-stretch justify-between gap-4 border-t ' + 'border-line pt-6', + [ + if (prev != null) _pagerLink(prev, next: false) else div(const []), + if (next != null) _pagerLink(next, next: true) else div(const []), + ], + ); + } + + Component _pagerLink(DocEntry entry, {required bool next}) { + return a( + href: entry.href, + classes: + 'flex flex-col gap-0.5 rounded-2xl border border-line bg-surface ' + 'px-5 py-3 transition-colors hover:border-accent ' + '${next ? 'items-end text-right' : 'items-start'}', + [ + span(classes: 'font-mono text-xs text-muted', [ + .text(next ? 'Next' : 'Previous'), + ]), + span(classes: 'font-medium text-ink', [ + .text(next ? '${entry.navLabel} →' : '← ${entry.navLabel}'), + ]), + ], + ); + } +} diff --git a/website/lib/docs/pages/accessibility_page.dart b/website/lib/docs/pages/accessibility_page.dart new file mode 100644 index 0000000..19f664e --- /dev/null +++ b/website/lib/docs/pages/accessibility_page.dart @@ -0,0 +1,115 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/accessibility` +class AccessibilityPage extends StatelessComponent { + const AccessibilityPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'accessibility', + toc: const [ + (id: 'keyboard', label: 'Keyboard'), + (id: 'announcements', label: 'Announcements'), + (id: 'handles', label: 'Handles & labels'), + ], + body: [ + docLead( + 'Accessibility is built in, not bolted on. Every draggable is ' + 'operable from the keyboard, and a live region narrates the drag so ' + 'screen-reader users follow the same interaction as everyone else.', + ), + youWillLearn(const [ + 'How keyboard dragging works out of the box.', + 'How drag progress is announced.', + 'How to label drag handles for assistive tech.', + ]), + docSection( + id: 'keyboard', + title: 'Keyboard', + children: [ + docProse( + 'Draggables are focusable and operable without a pointer. The ' + 'keyboard sensor is active by default:', + ), + docBullets(const [ + 'Space or Enter — pick up the focused item, and drop it.', + 'Arrow keys — move the lifted item between positions or targets.', + 'Escape — cancel the drag and return the item to its origin.', + ]), + ], + ), + docSection( + id: 'announcements', + title: 'Announcements', + children: [ + docProseRich([ + docText( + 'Drag progress is announced through a polite live region. On ' + 'Jaspr, mount a ', + ), + inlineCode('DndLiveRegion'), + docText( + ' inside the scope; on Flutter, the adapter emits semantics ' + 'announcements. Both speak the same shared ', + ), + inlineCode('DndAnnouncements'), + docText(' contract — pick up, move, drop, and cancel.'), + ]), + const CodeTabs( + flutterFile: 'a11y.dart', + jasprFile: 'a11y.dart', + flutter: _flutter, + jaspr: _jaspr, + ), + ], + ), + docSection( + id: 'handles', + title: 'Handles & labels', + children: [ + docProseRich([ + docText('Give every '), + inlineCode('DndDragHandle'), + docText(' a descriptive '), + inlineCode('label'), + docText( + ' so assistive technology announces what it moves — for ' + 'example "Reorder card: Buy milk" rather than an unlabeled grip.', + ), + ]), + ], + ), + nextSteps([ + NextStep( + label: 'Sortable lists', + desc: 'Keyboard-accessible reordering by default.', + href: docHref('sortable'), + ), + NextStep( + label: 'Overview', + desc: 'Back to the documentation home.', + href: docHref(''), + ), + ]), + ], + ); + } +} + +const _flutter = '''DndScope( + // Flutter: announcements are emitted as semantics events automatically. + child: board, +)'''; + +const _jaspr = '''DndScope( + child: div([ + board, + const DndLiveRegion(), // polite live region for screen readers + ]), +)'''; diff --git a/website/lib/docs/pages/autoscroll_page.dart b/website/lib/docs/pages/autoscroll_page.dart new file mode 100644 index 0000000..e33f37e --- /dev/null +++ b/website/lib/docs/pages/autoscroll_page.dart @@ -0,0 +1,107 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/auto-scroll` +class AutoscrollPage extends StatelessComponent { + const AutoscrollPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'auto-scroll', + toc: const [ + (id: 'how', label: 'How it works'), + (id: 'usage', label: 'Usage'), + (id: 'options', label: 'Options'), + ], + body: [ + docLead( + 'When a drag reaches the edge of a scrollable region, auto-scroll ' + 'moves the content to follow — so you can drag into parts of a list ' + 'or board that are off-screen.', + ), + youWillLearn(const [ + 'How edge auto-scroll is driven.', + 'How to wrap a scrollable region with DndAutoScroll.', + 'Which axis and options you can tune.', + ]), + docSection( + id: 'how', + title: 'How it works', + children: [ + docProse( + 'The scroll velocity comes from the same DOM-free math on both ' + 'adapters: the closer the pointer is to an edge, the faster the ' + 'region scrolls. Only the execution differs — Flutter drives a ' + 'Scrollable, Jaspr scrolls a DOM element.', + ), + ], + ), + docSection( + id: 'usage', + title: 'Usage', + children: [ + docProseRich([ + docText('Wrap the scrollable area in a '), + inlineCode('DndAutoScroll'), + docText(' inside the scope. On Flutter it drives the nearest '), + inlineCode('Scrollable'), + docText('; on Jaspr you make the wrapper scroll with '), + inlineCode('overflow'), + docText(' styles or classes.'), + ]), + const CodeTabs( + flutterFile: 'auto_scroll.dart', + jasprFile: 'auto_scroll.dart', + flutter: _flutterCode, + jaspr: _jasprCode, + ), + ], + ), + docSection( + id: 'options', + title: 'Options', + children: [ + docBullets(const [ + 'axis — DndScrollAxis.vertical (default) or horizontal.', + 'enabled — turn auto-scroll off without unwrapping.', + 'options — a DndAutoScrollOptions to tune edge thresholds and ' + 'speed.', + ]), + ], + ), + nextSteps([ + NextStep( + label: 'Sortable lists', + desc: 'Reorder long lists that scroll while dragging.', + href: docHref('sortable'), + ), + NextStep( + label: 'Multi-container sortable', + desc: 'Auto-scroll columns on a Kanban board.', + href: docHref('multi-container'), + ), + ]), + ], + ); + } +} + +const _flutterCode = '''DndScope( + child: DndAutoScroll( + axis: DndScrollAxis.vertical, + child: ListView(children: cards), + ), +)'''; + +const _jasprCode = '''DndScope( + child: DndAutoScroll( + axis: DndScrollAxis.vertical, + classes: 'max-h-[480px] overflow-auto', + child: div(cards), + ), +)'''; diff --git a/website/lib/docs/pages/collision_page.dart b/website/lib/docs/pages/collision_page.dart new file mode 100644 index 0000000..756fbe9 --- /dev/null +++ b/website/lib/docs/pages/collision_page.dart @@ -0,0 +1,124 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/collision` +class CollisionPage extends StatelessComponent { + const CollisionPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'collision', + toc: const [ + (id: 'detectors', label: 'Built-in detectors'), + (id: 'setting', label: 'Setting a detector'), + (id: 'composing', label: 'Composing'), + ], + body: [ + docLead( + 'When a draggable overlaps several droppables, a collision detector ' + 'decides which one wins. Detection is shared math in the engine, so ' + 'Flutter and Jaspr resolve the same target from the same input.', + ), + youWillLearn(const [ + 'The built-in collision detectors and when to use each.', + 'How to set a detector on a scope.', + 'How to combine detectors into a fallback chain.', + ]), + docSection( + id: 'detectors', + title: 'Built-in detectors', + children: [ + docProseRich([ + docText('All detectors live on '), + inlineCode('DndCollisionDetectors'), + docText(':'), + ]), + docBullets(const [ + 'closestCenter — the target whose center is nearest the pointer. ' + 'A solid default for grids and free layouts.', + 'closestCorners — compares corners instead of centers; steadier ' + 'when targets vary a lot in size.', + 'rectIntersection — the target the dragged rect overlaps most. ' + 'Natural for list and Kanban reordering.', + 'pointerWithin — only targets the pointer is literally inside. ' + 'Precise, but needs a pointer (not keyboard) drag.', + ]), + ], + ), + docSection( + id: 'setting', + title: 'Setting a detector', + children: [ + docProseRich([ + docText('Pass a detector to the '), + inlineCode('DndController'), + docText(' on your '), + inlineCode('DndScope'), + docText('. The default is '), + inlineCode('closestCenter'), + docText('.'), + ]), + const CodeTabs( + flutterFile: 'scope.dart', + jasprFile: 'scope.dart', + flutter: _setCode, + jaspr: _setCode, + ), + ], + ), + docSection( + id: 'composing', + title: 'Composing', + children: [ + docProseRich([ + docText('Use '), + inlineCode('compose'), + docText( + ' to try detectors in order and take the first that finds a ' + 'target — for example, prefer a precise pointer hit, then fall ' + 'back to overlap.', + ), + ]), + const CodeTabs( + flutterFile: 'scope.dart', + jasprFile: 'scope.dart', + flutter: _composeCode, + jaspr: _composeCode, + ), + ], + ), + nextSteps([ + NextStep( + label: 'Sensors & activation', + desc: 'Choose input methods and require intent.', + href: docHref('sensors'), + ), + NextStep( + label: 'Modifiers', + desc: 'Constrain the drag transform.', + href: docHref('modifiers'), + ), + ]), + ], + ); + } +} + +const _setCode = '''DndScope( + controller: DndController( + collisionDetector: DndCollisionDetectors.closestCenter, + ), + child: board, +)'''; + +const _composeCode = '''DndController( + collisionDetector: DndCollisionDetectors.compose([ + DndCollisionDetectors.pointerWithin, + DndCollisionDetectors.rectIntersection, + ]), +)'''; diff --git a/website/lib/docs/pages/draggable_page.dart b/website/lib/docs/pages/draggable_page.dart new file mode 100644 index 0000000..2fb6aa4 --- /dev/null +++ b/website/lib/docs/pages/draggable_page.dart @@ -0,0 +1,172 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/draggable` +class DraggablePage extends StatelessComponent { + const DraggablePage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'draggable', + toc: const [ + (id: 'usage', label: 'Usage'), + (id: 'handle', label: 'Drag handles'), + (id: 'activation', label: 'Activation'), + (id: 'events', label: 'Drag events'), + ], + body: [ + docLead( + 'A draggable is any element that can be picked up and moved. Wrap it ' + 'in DndDraggable, give it a stable DndId, and the engine handles the ' + 'pointer, keyboard, and transform.', + ), + youWillLearn(const [ + 'How to make an element draggable.', + 'How to restrict the drag surface to a handle.', + 'How to require intent before a drag begins.', + 'Which events fire over a drag lifecycle.', + ]), + docSection( + id: 'usage', + title: 'Usage', + children: [ + docProseRich([ + docText('Every draggable needs a unique '), + inlineCode('DndId'), + docText( + ' within its scope. The id is how collision results and drag ' + 'events refer back to it.', + ), + ]), + const CodeTabs( + flutterFile: 'draggable.dart', + jasprFile: 'draggable.dart', + flutter: _usageFlutter, + jaspr: _usageJaspr, + ), + ], + ), + docSection( + id: 'handle', + title: 'Drag handles', + children: [ + docProseRich([ + docText('Wrap part of the child in a '), + inlineCode('DndDragHandle'), + docText( + ' to make only that part the drag surface — useful when the ' + 'rest of the element stays interactive (links, buttons, text ' + 'selection).', + ), + ]), + const CodeTabs( + flutterFile: 'handle.dart', + jasprFile: 'handle.dart', + flutter: _handleFlutter, + jaspr: _handleJaspr, + ), + ], + ), + docSection( + id: 'activation', + title: 'Activation', + children: [ + docProseRich([ + docText('Pass a '), + inlineCode('DndSensorActivationConstraint'), + docText( + ' so a drag only starts after deliberate intent — a small ' + 'distance or a press delay — instead of on the first pixel of ' + 'movement. This keeps taps and clicks responsive.', + ), + ]), + const CodeTabs( + flutterFile: 'activation.dart', + jasprFile: 'activation.dart', + flutter: _activationFlutter, + jaspr: _activationJaspr, + ), + ], + ), + docSection( + id: 'events', + title: 'Drag events', + children: [ + docProse('A draggable reports its lifecycle through callbacks:'), + docBullets(const [ + 'onDragStart — the drag was activated and picked up.', + 'onDragUpdate — the pointer moved; the active transform changed.', + 'onDragEnd — the drag finished; read event.overId for the target ' + 'it landed on, or handle a cancel.', + ]), + ], + ), + nextSteps([ + NextStep( + label: 'Droppable', + desc: 'Define the targets a draggable can land on.', + href: docHref('droppable'), + ), + NextStep( + label: 'Drag overlay', + desc: 'Render a floating preview that follows the pointer.', + href: docHref('overlay'), + ), + ]), + ], + ); + } +} + +const _usageFlutter = '''DndDraggable( + id: const DndId('card-1'), + data: card, // optional payload carried through the drag + child: CardTile(card), +)'''; + +const _usageJaspr = '''DndDraggable( + id: const DndId('card-1'), + data: card, // optional payload carried through the drag + child: cardTile(card), +)'''; + +const _handleFlutter = '''DndDraggable( + id: const DndId('card-1'), + child: Row( + children: [ + DndDragHandle( + label: 'Reorder card', + child: const Icon(Icons.drag_indicator), + ), + const Expanded(child: Text('Card title')), + ], + ), +)'''; + +const _handleJaspr = '''DndDraggable( + id: const DndId('card-1'), + child: div([ + DndDragHandle( + label: 'Reorder card', + child: span([.text('⠿')]), + ), + span([.text('Card title')]), + ]), +)'''; + +const _activationFlutter = '''DndDraggable( + id: const DndId('card-1'), + constraint: const DndSensorActivationConstraint(distance: 6), + child: CardTile(card), +)'''; + +const _activationJaspr = '''DndDraggable( + id: const DndId('card-1'), + constraint: const DndSensorActivationConstraint(distance: 6), + child: cardTile(card), +)'''; diff --git a/website/lib/docs/pages/droppable_page.dart b/website/lib/docs/pages/droppable_page.dart new file mode 100644 index 0000000..4066757 --- /dev/null +++ b/website/lib/docs/pages/droppable_page.dart @@ -0,0 +1,133 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/droppable` +class DroppablePage extends StatelessComponent { + const DroppablePage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'droppable', + toc: const [ + (id: 'usage', label: 'Usage'), + (id: 'collision', label: 'Collision detection'), + (id: 'feedback', label: 'Hover feedback'), + ], + body: [ + docLead( + 'A droppable is a target a draggable can land on. Mark an area with ' + 'DndDroppable and a unique id; the engine reports when a draggable ' + 'is over it and which target a drag ends on.', + ), + youWillLearn(const [ + 'How to define a drop target.', + 'How the engine decides which target is under the pointer.', + 'How to give visual feedback while a draggable hovers.', + ]), + docSection( + id: 'usage', + title: 'Usage', + children: [ + docProseRich([ + docText('Give each droppable a unique '), + inlineCode('DndId'), + docText('. On the draggable, '), + inlineCode('onDragEnd'), + docText(' reports the target id through '), + inlineCode('event.overId'), + docText('.'), + ]), + const CodeTabs( + flutterFile: 'droppable.dart', + jasprFile: 'droppable.dart', + flutter: _usageFlutter, + jaspr: _usageJaspr, + ), + ], + ), + docSection( + id: 'collision', + title: 'Collision detection', + children: [ + docProse( + 'When a draggable overlaps several droppables, a collision ' + 'strategy decides which one wins. The strategy is shared math in ' + 'the engine, so Flutter and Jaspr resolve the same target:', + ), + docBullets(const [ + 'Closest center — the target whose center is nearest the ' + 'pointer. A solid default for grids and free layouts.', + 'Largest overlap — the target the dragged element overlaps most. ' + 'Natural for list and Kanban reordering.', + ]), + ], + ), + docSection( + id: 'feedback', + title: 'Hover feedback', + children: [ + docProse( + 'Read whether a target is currently being hovered to highlight ' + 'it — the drop zones on this site light up exactly this way.', + ), + const CodeTabs( + flutterFile: 'feedback.dart', + jasprFile: 'feedback.dart', + flutter: _feedbackFlutter, + jaspr: _feedbackJaspr, + ), + ], + ), + nextSteps([ + NextStep( + label: 'Drag overlay', + desc: 'Render a floating preview that follows the pointer.', + href: docHref('overlay'), + ), + NextStep( + label: 'Sortable lists', + desc: 'Reorder a list without wiring targets by hand.', + href: docHref('sortable'), + ), + ]), + ], + ); + } +} + +const _usageFlutter = '''DndDroppable( + id: const DndId('inbox'), + child: const SizedBox(width: 240, height: 160, child: Text('Inbox')), +)'''; + +const _usageJaspr = '''DndDroppable( + id: const DndId('inbox'), + child: div([.text('Inbox')]), +)'''; + +const _feedbackFlutter = '''DndDroppable( + id: const DndId('inbox'), + builder: (context, state, child) => DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: state.isOver ? Colors.orange : Colors.grey, + ), + ), + child: child, + ), + child: const Text('Inbox'), +)'''; + +const _feedbackJaspr = '''DndDroppable( + id: const DndId('inbox'), + builder: (context, state, child) => div( + classes: state.isOver ? 'border-accent' : 'border-line', + [child], + ), + child: div([.text('Inbox')]), +)'''; diff --git a/website/lib/docs/pages/install_page.dart b/website/lib/docs/pages/install_page.dart new file mode 100644 index 0000000..6b0bb77 --- /dev/null +++ b/website/lib/docs/pages/install_page.dart @@ -0,0 +1,85 @@ +import 'package:jaspr/jaspr.dart'; + +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/install` +class InstallPage extends StatelessComponent { + const InstallPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'install', + toc: const [ + (id: 'flutter', label: 'Flutter'), + (id: 'jaspr', label: 'Jaspr (web)'), + (id: 'engine', label: 'Shared engine'), + ], + body: [ + docLead( + 'Add one package for your platform. Each adapter depends on the ' + 'shared engine for you — you only depend on dnd_kit directly when ' + 'building a custom adapter or testing the drag math.', + ), + youWillLearn(const [ + 'Which package to add for Flutter, Jaspr, or a custom adapter.', + 'The current stable version line of the family.', + ]), + docSection( + id: 'flutter', + title: 'Flutter', + children: [ + docProseRich([ + docText('Add '), + inlineCode('dnd_kit_flutter'), + docText(' to a Flutter app:'), + ]), + docCodeBlock( + 'pubspec.yaml', + 'dependencies:\n dnd_kit_flutter: ^0.4.0', + ), + ], + ), + docSection( + id: 'jaspr', + title: 'Jaspr (web)', + children: [ + docProseRich([ + docText('Add '), + inlineCode('dnd_kit_jaspr'), + docText(' to a Jaspr app. It needs no Flutter SDK:'), + ]), + docCodeBlock( + 'pubspec.yaml', + 'dependencies:\n dnd_kit_jaspr: ^0.4.0', + ), + ], + ), + docSection( + id: 'engine', + title: 'Shared engine', + children: [ + docProseRich([ + docText('Depend on '), + inlineCode('dnd_kit'), + docText( + ' directly only for custom adapters or contract tests — the ' + 'adapters already bundle it:', + ), + ]), + docCodeBlock('pubspec.yaml', 'dependencies:\n dnd_kit: ^0.4.0'), + ], + ), + nextSteps([ + NextStep( + label: 'Quickstart', + desc: 'Wire your first drag-and-drop in three steps.', + href: docHref('quickstart'), + ), + ]), + ], + ); + } +} diff --git a/website/lib/docs/pages/modifiers_page.dart b/website/lib/docs/pages/modifiers_page.dart new file mode 100644 index 0000000..7fd9a71 --- /dev/null +++ b/website/lib/docs/pages/modifiers_page.dart @@ -0,0 +1,119 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/modifiers` +class ModifiersPage extends StatelessComponent { + const ModifiersPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'modifiers', + toc: const [ + (id: 'what', label: 'What modifiers do'), + (id: 'builtin', label: 'Built-in modifiers'), + (id: 'applying', label: 'Applying them'), + ], + body: [ + docLead( + 'Modifiers adjust the active drag transform before it is applied — ' + 'constrain movement to an axis, snap to a grid, or clamp within a ' + 'boundary. Each is a pure function, so they compose cleanly.', + ), + youWillLearn(const [ + 'What a modifier is.', + 'The built-in modifiers the engine ships.', + 'How to apply one or several at once.', + ]), + docSection( + id: 'what', + title: 'What modifiers do', + children: [ + docProseRich([ + docText('A modifier is a function from a '), + inlineCode('DndModifierInput'), + docText(' to a '), + inlineCode('DndTransform'), + docText( + '. The engine runs it on every frame of a drag, so the change ' + 'is live and identical on both adapters.', + ), + ]), + ], + ), + docSection( + id: 'builtin', + title: 'Built-in modifiers', + children: [ + docProseRich([ + docText('They live on '), + inlineCode('DndModifiers'), + docText(':'), + ]), + docBullets(const [ + 'restrictToHorizontalAxis — lock movement to the x axis.', + 'restrictToVerticalAxis — lock movement to the y axis.', + 'restrictToBoundary(rect) — clamp the element within a rectangle.', + 'snapToGrid(width: , height: ) — quantize movement to a grid.', + 'compose(modifiers) — apply several in order, first to last.', + ]), + ], + ), + docSection( + id: 'applying', + title: 'Applying them', + children: [ + docProseRich([ + docText('Pass modifiers to the '), + inlineCode('DndController'), + docText(' on your scope:'), + ]), + const CodeTabs( + flutterFile: 'scope.dart', + jasprFile: 'scope.dart', + flutter: _oneCode, + jaspr: _oneCode, + ), + docProse('Combine several — snap to a grid and clamp to a box:'), + const CodeTabs( + flutterFile: 'scope.dart', + jasprFile: 'scope.dart', + flutter: _manyCode, + jaspr: _manyCode, + ), + ], + ), + nextSteps([ + NextStep( + label: 'Auto-scroll', + desc: 'Scroll a container while dragging past its edge.', + href: docHref('auto-scroll'), + ), + NextStep( + label: 'Collision detection', + desc: 'Decide which target a draggable lands on.', + href: docHref('collision'), + ), + ]), + ], + ); + } +} + +const _oneCode = '''DndScope( + controller: DndController( + modifiers: [DndModifiers.restrictToHorizontalAxis], + ), + child: board, +)'''; + +const _manyCode = '''DndController( + modifiers: [ + DndModifiers.snapToGrid(width: 16, height: 16), + DndModifiers.restrictToBoundary(boardRect), + ], +)'''; diff --git a/website/lib/docs/pages/multi_container_page.dart b/website/lib/docs/pages/multi_container_page.dart new file mode 100644 index 0000000..6f3b8a3 --- /dev/null +++ b/website/lib/docs/pages/multi_container_page.dart @@ -0,0 +1,141 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/multi-container` +class MultiContainerPage extends StatelessComponent { + const MultiContainerPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'multi-container', + toc: const [ + (id: 'anatomy', label: 'Anatomy'), + (id: 'usage', label: 'Usage'), + (id: 'state', label: 'Applying moves'), + ], + body: [ + docLead( + 'Multi-container sortable moves items within and across lists — the ' + 'shape of a Kanban board. It is a supported preset over the shared ' + 'engine, so the same code drives Flutter and the web.', + ), + youWillLearn(const [ + 'The three components that make a multi-list board.', + 'How to wire columns and cards.', + 'How to apply a cross-list move to your own state.', + ]), + docSection( + id: 'anatomy', + title: 'Anatomy', + children: [ + docBullets(const [ + 'SortableMultiScope — owns the whole board and the move policy ' + 'across all columns.', + 'SortableMultiContainerArea — one column; a drop region that ' + 'holds an ordered list of items.', + 'SortableMultiItem — one card; draggable within and between ' + 'columns.', + ]), + ], + ), + docSection( + id: 'usage', + title: 'Usage', + children: [ + docProse( + 'Nest the three components and feed them your board model — a map ' + 'of column id to the ordered card ids in that column.', + ), + const CodeTabs( + flutterFile: 'board.dart', + jasprFile: 'board.dart', + flutter: _usageFlutter, + jaspr: _usageJaspr, + ), + ], + ), + docSection( + id: 'state', + title: 'Applying moves', + children: [ + docProse( + 'A move reports the source and target column plus the indices. ' + 'You remove the card from its old column and insert it into the ' + 'new one — the board owns its data, the engine only resolves ' + 'intent. The Kanban showcase on the home page is built on exactly ' + 'this preset.', + ), + const CodeTabs( + flutterFile: 'on_move.dart', + jasprFile: 'on_move.dart', + flutter: _moveCode, + jaspr: _moveCode, + ), + ], + ), + nextSteps([ + NextStep( + label: 'Sortable lists', + desc: 'The single-list preset this builds on.', + href: docHref('sortable'), + ), + NextStep( + label: 'Accessibility', + desc: 'Keyboard moves across columns, announced.', + href: docHref('accessibility'), + ), + ]), + ], + ); + } +} + +const _usageFlutter = '''SortableMultiScope( + controller: controller, + columnIds: columns, // List<DndId> + onMove: _onMove, + child: Row( + children: [ + for (final columnId in columns) + SortableMultiContainerArea( + id: columnId, + itemIds: board[columnId]!, + child: Column( + children: [ + for (final cardId in board[columnId]!) + SortableMultiItem(id: cardId, child: CardTile(cardId)), + ], + ), + ), + ], + ), +)'''; + +const _usageJaspr = '''SortableMultiScope( + controller: controller, + columnIds: columns, // List<DndId> + onMove: _onMove, + child: div([ + for (final columnId in columns) + SortableMultiContainerArea( + id: columnId, + itemIds: board[columnId]!, + child: div([ + for (final cardId in board[columnId]!) + SortableMultiItem(id: cardId, child: cardTile(cardId)), + ]), + ), + ]), +)'''; + +const _moveCode = '''void _onMove(SortableMultiMoveDetails details) { + setState(() { + final card = board[details.fromColumn]!.removeAt(details.fromIndex); + board[details.toColumn]!.insert(details.toIndex, card); + }); +}'''; diff --git a/website/lib/docs/pages/overlay_page.dart b/website/lib/docs/pages/overlay_page.dart new file mode 100644 index 0000000..6b0dc4f --- /dev/null +++ b/website/lib/docs/pages/overlay_page.dart @@ -0,0 +1,98 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/overlay` +class OverlayPage extends StatelessComponent { + const OverlayPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'overlay', + toc: const [ + (id: 'why', label: 'Why an overlay'), + (id: 'usage', label: 'Usage'), + ], + body: [ + docLead( + 'A drag overlay is a floating preview that follows the pointer while ' + 'the original element keeps its place in the layout. It avoids ' + 'clipping and reflow during a drag.', + ), + youWillLearn(const [ + 'When a drag overlay helps over moving the element in place.', + 'How to render an overlay for the active draggable.', + ]), + docSection( + id: 'why', + title: 'Why an overlay', + children: [ + docProse( + 'Moving the element itself works for simple cases, but a dragged ' + 'element inside a scrollable or clipped container can be cut off, ' + 'and reordering its siblings can cause layout jumps. An overlay ' + 'renders the drag preview in a top layer instead, so it stays ' + 'crisp above everything and never reflows the list underneath.', + ), + ], + ), + docSection( + id: 'usage', + title: 'Usage', + children: [ + docProseRich([ + docText('Place a '), + inlineCode('DndDragOverlay'), + docText( + ' inside the scope. It renders its builder only while a drag is ' + 'active, positioned to follow the pointer.', + ), + ]), + const CodeTabs( + flutterFile: 'overlay.dart', + jasprFile: 'overlay.dart', + flutter: _usageFlutter, + jaspr: _usageJaspr, + ), + ], + ), + nextSteps([ + NextStep( + label: 'Sortable lists', + desc: 'Reorder a list with the sortable preset.', + href: docHref('sortable'), + ), + NextStep( + label: 'Accessibility', + desc: 'Keyboard dragging and screen-reader announcements.', + href: docHref('accessibility'), + ), + ]), + ], + ); + } +} + +const _usageFlutter = '''DndScope( + child: Stack( + children: [ + board, + DndDragOverlay( + builder: (context, activeId) => CardTile(cardFor(activeId)), + ), + ], + ), +)'''; + +const _usageJaspr = '''DndScope( + child: div([ + board, + DndDragOverlay( + builder: (context, activeId) => cardTile(cardFor(activeId)), + ), + ]), +)'''; diff --git a/website/lib/docs/pages/overview_page.dart b/website/lib/docs/pages/overview_page.dart new file mode 100644 index 0000000..b343dbe --- /dev/null +++ b/website/lib/docs/pages/overview_page.dart @@ -0,0 +1,109 @@ +import 'package:jaspr/jaspr.dart'; + +import '../../data/site_data.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs` — the documentation landing page. +class OverviewPage extends StatelessComponent { + const OverviewPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: '', + toc: const [ + (id: 'features', label: 'Key features'), + (id: 'packages', label: 'Choose a package'), + (id: 'concepts', label: 'Core concepts'), + ], + body: [ + docLead( + 'dnd_kit is one drag-and-drop engine for Flutter and the web. A ' + 'framework-neutral core does the drag math; thin adapters render it ' + 'on each platform — so the same API moves cards on Flutter and in ' + 'the browser.', + ), + docSection( + id: 'features', + title: 'Key features', + children: [ + docBullets(const [ + 'One engine, two adapters — identical collision, modifier, and ' + 'sortable math on Flutter and the web.', + 'Batteries included — draggable, droppable, drag overlay, ' + 'sortable lists, and multi-container Kanban presets.', + 'Accessible by default — keyboard dragging and live-region ' + 'announcements are built in, not bolted on.', + 'SSR-safe on the web — components pre-render on the server and ' + 'hydrate without DOM access at import time.', + ]), + ], + ), + docSection( + id: 'packages', + title: 'Choose a package', + children: [ + docProse('Pick the package for your platform, then read on:'), + nextSteps([ + NextStep( + label: 'dnd_kit_flutter', + desc: 'Flutter apps — widgets, sensors, overlays, sortable.', + href: SiteLinks.pubFlutter, + external: true, + ), + NextStep( + label: 'dnd_kit_jaspr', + desc: + 'Jaspr (Dart web) apps — the web adapter, no Flutter SDK.', + href: SiteLinks.pubJaspr, + external: true, + ), + NextStep( + label: 'dnd_kit', + desc: 'The shared engine — custom adapters and drag math.', + href: SiteLinks.pubKit, + external: true, + ), + NextStep( + label: 'Installation', + desc: 'Add a package and start dragging.', + href: docHref('install'), + ), + ]), + ], + ), + docSection( + id: 'concepts', + title: 'Core concepts', + children: [ + docProse('The essential building blocks, smallest first:'), + nextSteps([ + NextStep( + label: 'Quickstart', + desc: 'Wire a draggable and a droppable in three steps.', + href: docHref('quickstart'), + ), + NextStep( + label: 'Draggable', + desc: 'Make any widget or element pick-up-able.', + href: docHref('draggable'), + ), + NextStep( + label: 'Droppable', + desc: 'Define the targets a draggable can land on.', + href: docHref('droppable'), + ), + NextStep( + label: 'Sortable lists', + desc: 'Reorder a list with the sortable preset.', + href: docHref('sortable'), + ), + ]), + ], + ), + ], + ); + } +} diff --git a/website/lib/docs/pages/quickstart_page.dart b/website/lib/docs/pages/quickstart_page.dart new file mode 100644 index 0000000..1bbc52b --- /dev/null +++ b/website/lib/docs/pages/quickstart_page.dart @@ -0,0 +1,187 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/quickstart` +class QuickstartPage extends StatelessComponent { + const QuickstartPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'quickstart', + toc: const [ + (id: 'scope', label: 'Wrap in a scope'), + (id: 'draggable', label: 'Make a draggable'), + (id: 'droppable', label: 'Add a droppable'), + (id: 'together', label: 'Put it together'), + ], + body: [ + docLead( + 'Three steps to your first drag-and-drop: wrap an area in a scope, ' + 'mark a draggable and a drop target, then react when they meet. The ' + 'API is the same on Flutter and Jaspr — switch the tab on any ' + 'snippet to compare.', + ), + youWillLearn(const [ + 'How to wrap an interactive area in a DndScope.', + 'How to make an element draggable and define a drop target.', + 'How to react to a drop with onDragEnd.', + ]), + docSection( + id: 'scope', + title: 'Wrap in a scope', + children: [ + docProseRich([ + docText('A '), + inlineCode('DndScope'), + docText( + ' owns one drag interaction. Everything draggable or droppable ' + 'lives inside it. Start by wrapping the area you want to make ' + 'interactive.', + ), + ]), + ], + ), + docSection( + id: 'draggable', + title: 'Make a draggable', + children: [ + docProseRich([ + docText('Wrap any element in a '), + inlineCode('DndDraggable'), + docText(' with a unique '), + inlineCode('DndId'), + docText('. That is all it takes to pick it up.'), + ]), + const CodeTabs( + flutterFile: 'draggable.dart', + jasprFile: 'draggable.dart', + flutter: _draggableFlutter, + jaspr: _draggableJaspr, + ), + ], + ), + docSection( + id: 'droppable', + title: 'Add a droppable', + children: [ + docProseRich([ + docText('A '), + inlineCode('DndDroppable'), + docText( + ' is a target a draggable can land on. It also takes a unique ' + 'id, which you read back when the drag ends.', + ), + ]), + const CodeTabs( + flutterFile: 'droppable.dart', + jasprFile: 'droppable.dart', + flutter: _droppableFlutter, + jaspr: _droppableJaspr, + ), + ], + ), + docSection( + id: 'together', + title: 'Put it together', + children: [ + docProseRich([ + docText('Listen to '), + inlineCode('onDragEnd'), + docText( + ' to move your data when a draggable lands on a target. You own ' + 'the state; dnd_kit reports the move.', + ), + ]), + const CodeTabs( + flutterFile: 'main.dart', + jasprFile: 'main.dart', + flutter: _togetherFlutter, + jaspr: _togetherJaspr, + ), + ], + ), + nextSteps([ + NextStep( + label: 'Draggable', + desc: 'Drag handles, activation, and drag data in depth.', + href: docHref('draggable'), + ), + NextStep( + label: 'Sortable lists', + desc: 'Reorder a list with the sortable preset.', + href: docHref('sortable'), + ), + ]), + ], + ); + } +} + +const _draggableFlutter = + '''import 'package:dnd_kit_flutter/dnd_kit_flutter.dart'; +import 'package:flutter/widgets.dart'; + +DndDraggable( + id: const DndId('card'), + child: const Text('Drag me'), +)'''; + +const _draggableJaspr = '''import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart'; + +DndDraggable( + id: const DndId('card'), + child: div([.text('Drag me')]), +)'''; + +const _droppableFlutter = '''DndDroppable( + id: const DndId('inbox'), + child: const Text('Inbox'), +)'''; + +const _droppableJaspr = '''DndDroppable( + id: const DndId('inbox'), + child: div([.text('Inbox')]), +)'''; + +const _togetherFlutter = '''DndScope( + child: Column( + children: [ + DndDraggable( + id: const DndId('card'), + onDragEnd: (event) { + if (event.overId == const DndId('inbox')) { + moveCardToInbox(); + } + }, + child: const Text('Drag me'), + ), + DndDroppable( + id: const DndId('inbox'), + child: const Text('Inbox'), + ), + ], + ), +)'''; + +const _togetherJaspr = '''DndScope( + child: div([ + DndDraggable( + id: const DndId('card'), + onDragEnd: (event) { + if (event.overId == const DndId('inbox')) { + moveCardToInbox(); + } + }, + child: div([.text('Drag me')]), + ), + DndDroppable( + id: const DndId('inbox'), + child: div([.text('Inbox')]), + ), + ]), +)'''; diff --git a/website/lib/docs/pages/reference_page.dart b/website/lib/docs/pages/reference_page.dart new file mode 100644 index 0000000..c091ba8 --- /dev/null +++ b/website/lib/docs/pages/reference_page.dart @@ -0,0 +1,106 @@ +import 'package:jaspr/jaspr.dart'; + +import '../../data/site_data.dart'; +import '../doc_components.dart'; +import '../docs_shell.dart'; + +/// `/docs/reference` +class ReferencePage extends StatelessComponent { + const ReferencePage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'reference', + toc: const [ + (id: 'api', label: 'API docs'), + (id: 'changelog', label: 'Changelog'), + (id: 'source', label: 'Source & issues'), + ], + body: [ + docLead( + 'The full generated API reference lives on pub.dev. Browse the ' + 'package you depend on, or read the engine for the shared contracts.', + ), + docSection( + id: 'api', + title: 'API docs', + children: [ + nextSteps([ + NextStep( + label: 'dnd_kit_flutter', + desc: 'Flutter adapter — widgets, sensors, overlays, sortable.', + href: _doc('dnd_kit_flutter'), + external: true, + ), + NextStep( + label: 'dnd_kit_jaspr', + desc: + 'Jaspr (web) adapter — components over the shared engine.', + href: _doc('dnd_kit_jaspr'), + external: true, + ), + NextStep( + label: 'dnd_kit', + desc: 'Shared engine — collision, modifiers, sortable math.', + href: _doc('dnd_kit'), + external: true, + ), + ]), + ], + ), + docSection( + id: 'changelog', + title: 'Changelog', + children: [ + docProse( + 'Each package publishes its changelog on pub.dev. The family ' + 'releases in lockstep — engine first, then the adapters.', + ), + nextSteps([ + NextStep( + label: 'dnd_kit_flutter changelog', + desc: 'Release notes for the Flutter adapter.', + href: _changelog('dnd_kit_flutter'), + external: true, + ), + NextStep( + label: 'dnd_kit_jaspr changelog', + desc: 'Release notes for the web adapter.', + href: _changelog('dnd_kit_jaspr'), + external: true, + ), + NextStep( + label: 'dnd_kit changelog', + desc: 'Release notes for the shared engine.', + href: _changelog('dnd_kit'), + external: true, + ), + ]), + ], + ), + docSection( + id: 'source', + title: 'Source & issues', + children: [ + docProse( + 'The whole family lives in one repository. File issues and read ' + 'the examples there.', + ), + nextSteps([ + NextStep( + label: 'GitHub repository', + desc: 'Source, examples, and issue tracker.', + href: SiteLinks.github, + external: true, + ), + ]), + ], + ), + ], + ); + } +} + +String _doc(String pkg) => 'https://pub.dev/documentation/$pkg/latest/'; +String _changelog(String pkg) => 'https://pub.dev/packages/$pkg/changelog'; diff --git a/website/lib/docs/pages/sensors_page.dart b/website/lib/docs/pages/sensors_page.dart new file mode 100644 index 0000000..cf89e09 --- /dev/null +++ b/website/lib/docs/pages/sensors_page.dart @@ -0,0 +1,121 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/sensors` +class SensorsPage extends StatelessComponent { + const SensorsPage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'sensors', + toc: const [ + (id: 'what', label: 'What sensors do'), + (id: 'activation', label: 'Activation constraints'), + (id: 'keyboard', label: 'Keyboard'), + ], + body: [ + docLead( + 'Sensors translate raw input — pointer, keyboard — into drag intent. ' + 'The pointer sensor is active by default; activation constraints ' + 'decide how deliberate a gesture must be before a drag begins.', + ), + youWillLearn(const [ + 'How sensors turn input into drags.', + 'How to require a distance or delay before activation.', + 'How keyboard dragging fits in.', + ]), + docSection( + id: 'what', + title: 'What sensors do', + children: [ + docProseRich([ + docText('A '), + inlineCode('DndSensor'), + docText( + ' watches an input source and starts, updates, and ends ' + 'a drag on the controller. The built-in ', + ), + inlineCode('DndPointerSensor'), + docText( + ' handles mouse, touch, and pen through unified pointer ' + 'events, so the same code works across devices.', + ), + ]), + ], + ), + docSection( + id: 'activation', + title: 'Activation constraints', + children: [ + docProseRich([ + docText('A '), + inlineCode('DndSensorActivationConstraint'), + docText( + ' delays activation until the gesture clears a threshold, so a ' + 'tap or click is never mistaken for a drag. Set a small ' + 'distance for immediate-feeling drags, or a delay for ' + 'press-and-hold.', + ), + ]), + const CodeTabs( + flutterFile: 'activation.dart', + jasprFile: 'activation.dart', + flutter: _distanceCode, + jaspr: _distanceCode, + ), + docProse('A press delay instead of a distance:'), + const CodeTabs( + flutterFile: 'activation.dart', + jasprFile: 'activation.dart', + flutter: _delayCode, + jaspr: _delayCode, + ), + ], + ), + docSection( + id: 'keyboard', + title: 'Keyboard', + children: [ + docProse( + 'Keyboard dragging is built in: a focused draggable is picked up ' + 'with space or enter, moved with the arrow keys, and cancelled ' + 'with escape — no extra wiring. See the accessibility page for ' + 'the full keyboard model.', + ), + ], + ), + nextSteps([ + NextStep( + label: 'Accessibility', + desc: 'The full keyboard and announcement model.', + href: docHref('accessibility'), + ), + NextStep( + label: 'Modifiers', + desc: 'Constrain the drag transform.', + href: docHref('modifiers'), + ), + ]), + ], + ); + } +} + +const _distanceCode = '''DndDraggable( + id: const DndId('card'), + constraint: const DndSensorActivationConstraint(distance: 6), + child: card, +)'''; + +const _delayCode = '''DndDraggable( + id: const DndId('card'), + constraint: const DndSensorActivationConstraint( + delay: Duration(milliseconds: 200), + ), + child: card, +)'''; diff --git a/website/lib/docs/pages/sortable_page.dart b/website/lib/docs/pages/sortable_page.dart new file mode 100644 index 0000000..c69862b --- /dev/null +++ b/website/lib/docs/pages/sortable_page.dart @@ -0,0 +1,138 @@ +import 'package:jaspr/jaspr.dart'; + +import '../code_tabs.dart'; +import '../doc_components.dart'; +import '../docs_nav.dart'; +import '../docs_shell.dart'; + +/// `/docs/sortable` +class SortablePage extends StatelessComponent { + const SortablePage({super.key}); + + @override + Component build(BuildContext context) { + return DocsShell( + slug: 'sortable', + toc: const [ + (id: 'usage', label: 'Usage'), + (id: 'strategies', label: 'Strategies'), + (id: 'state', label: 'Managing state'), + ], + body: [ + docLead( + 'The sortable preset turns a list into a reorderable one without ' + 'wiring draggables and droppables by hand. Wrap the list in a ' + 'SortableScope and each item in a SortableItem.', + ), + youWillLearn(const [ + 'How to make a single list reorderable.', + 'Which layout strategy to pick for lists and grids.', + 'How to apply the reported move to your own state.', + ]), + docSection( + id: 'usage', + title: 'Usage', + children: [ + docProseRich([ + docText('A '), + inlineCode('SortableScope'), + docText(' takes the ordered '), + inlineCode('itemIds'), + docText(' and an '), + inlineCode('onMove'), + docText(' callback; each child is a '), + inlineCode('SortableItem'), + docText(' with a matching id.'), + ]), + const CodeTabs( + flutterFile: 'sortable.dart', + jasprFile: 'sortable.dart', + flutter: _usageFlutter, + jaspr: _usageJaspr, + ), + ], + ), + docSection( + id: 'strategies', + title: 'Strategies', + children: [ + docProseRich([ + docText('Pick a '), + inlineCode('SortableStrategies'), + docText(' to match your layout:'), + ]), + docBullets(const [ + 'verticalList — a stacked column of items.', + 'horizontalList — a row of items, e.g. tabs or nav pills.', + 'grid — a wrapping grid that reflows in two dimensions.', + ]), + ], + ), + docSection( + id: 'state', + title: 'Managing state', + children: [ + docProse( + 'dnd_kit reports a move as from/to indices — you own the list and ' + 'apply it. Reordering is a remove-then-insert on your data:', + ), + const CodeTabs( + flutterFile: 'on_move.dart', + jasprFile: 'on_move.dart', + flutter: _moveFlutter, + jaspr: _moveJaspr, + ), + ], + ), + nextSteps([ + NextStep( + label: 'Multi-container sortable', + desc: 'Move items across lists — a Kanban board.', + href: docHref('multi-container'), + ), + NextStep( + label: 'Accessibility', + desc: 'Keyboard reordering and announcements.', + href: docHref('accessibility'), + ), + ]), + ], + ); + } +} + +const _usageFlutter = '''SortableScope( + controller: controller, + strategy: SortableStrategies.verticalList, + itemIds: order, // List<DndId> + onMove: _onMove, + child: Column( + children: [ + for (final id in order) SortableItem(id: id, child: CardTile(id)), + ], + ), +)'''; + +const _usageJaspr = '''SortableScope( + controller: controller, + strategy: SortableStrategies.verticalList, + itemIds: order, // List<DndId> + onMove: _onMove, + child: div([ + for (final id in order) SortableItem(id: id, child: cardTile(id)), + ]), +)'''; + +const _moveFlutter = '''void _onMove(SortableMoveDetails details) { + setState(() { + final id = order.removeAt(details.fromIndex); + order.insert(details.toIndex, id); + }); +}'''; + +const _moveJaspr = '''void _onMove(SortableMoveDetails details) { + setState(() { + final id = order.removeAt(details.fromIndex); + order.insert(details.toIndex, id); + }); +}'''; diff --git a/website/lib/layout/mobile_nav.dart b/website/lib/layout/mobile_nav.dart index 662aa60..af0cc60 100644 --- a/website/lib/layout/mobile_nav.dart +++ b/website/lib/layout/mobile_nav.dart @@ -28,8 +28,11 @@ class _MobileNavState extends State<MobileNav> { if (kIsWeb) { if (href.startsWith('#')) { web.window.location.hash = href; - } else { + } else if (href.startsWith('http')) { web.window.open(href, '_blank'); + } else { + // Internal route (e.g. the docs page) — navigate in the same tab. + web.window.location.href = href; } } _close(); diff --git a/website/lib/layout/nav_bar.dart b/website/lib/layout/nav_bar.dart index acc3199..3770b14 100644 --- a/website/lib/layout/nav_bar.dart +++ b/website/lib/layout/nav_bar.dart @@ -10,7 +10,10 @@ import 'mobile_nav.dart'; /// Sticky top navigation. The in-page links are reorderable (drag a pill to /// rearrange them) while still navigating on a plain click. class NavBar extends StatelessComponent { - const NavBar({super.key}); + const NavBar({this.activeDocs = false, super.key}); + + /// Highlights the Docs pill when the docs page is the current route. + final bool activeDocs; @override Component build(BuildContext context) { @@ -43,7 +46,10 @@ class NavBar extends StatelessComponent { ), a( href: SiteLinks.docs, - classes: 'pill-link hidden sm:inline-block', + classes: + 'pill-link hidden sm:inline-block' + '${activeDocs ? ' text-accent' : ''}', + attributes: activeDocs ? const {'aria-current': 'page'} : null, [.text('Docs')], ), const ThemeToggle(), diff --git a/website/lib/main.client.options.dart b/website/lib/main.client.options.dart index 0335885..2e6ed18 100644 --- a/website/lib/main.client.options.dart +++ b/website/lib/main.client.options.dart @@ -6,6 +6,7 @@ import 'package:jaspr/client.dart'; +import 'package:dnd_kit_website/docs/code_tabs.dart' deferred as _code_tabs; import 'package:dnd_kit_website/drag/telemetry_hud.dart' deferred as _telemetry_hud; import 'package:dnd_kit_website/layout/mobile_nav.dart' deferred as _mobile_nav; @@ -39,6 +40,15 @@ import 'package:dnd_kit_website/theme/theme_toggle.dart' /// ``` ClientOptions get defaultClientOptions => ClientOptions( clients: { + 'code_tabs': ClientLoader( + (p) => _code_tabs.CodeTabs( + flutter: p['flutter'] as String, + jaspr: p['jaspr'] as String, + flutterFile: p['flutterFile'] as String, + jasprFile: p['jasprFile'] as String, + ), + loader: _code_tabs.loadLibrary, + ), 'telemetry_hud': ClientLoader( (p) => _telemetry_hud.TelemetryHud(), loader: _telemetry_hud.loadLibrary, diff --git a/website/lib/main.server.dart b/website/lib/main.server.dart index 043d722..a704993 100644 --- a/website/lib/main.server.dart +++ b/website/lib/main.server.dart @@ -1,8 +1,8 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/server.dart'; +import 'app.dart'; import 'main.server.options.dart'; -import 'site.dart'; /// Google Fonts: Newsreader (display serif), Hanken Grotesk (body), /// Geist Mono (utility/code). @@ -88,7 +88,7 @@ void main() { children: const [RawText(_noFlashScript)], ), ], - body: const Site(), + body: const App(), ), ); } diff --git a/website/lib/main.server.options.dart b/website/lib/main.server.options.dart index 39ae320..f1361fa 100644 --- a/website/lib/main.server.options.dart +++ b/website/lib/main.server.options.dart @@ -5,6 +5,7 @@ // Generated with jaspr_builder import 'package:jaspr/server.dart'; +import 'package:dnd_kit_website/docs/code_tabs.dart' as _code_tabs; import 'package:dnd_kit_website/drag/telemetry_hud.dart' as _telemetry_hud; import 'package:dnd_kit_website/layout/mobile_nav.dart' as _mobile_nav; import 'package:dnd_kit_website/layout/nav_bar.dart' as _nav_bar; @@ -35,6 +36,10 @@ import 'package:dnd_kit_website/theme/theme_toggle.dart' as _theme_toggle; ServerOptions get defaultServerOptions => ServerOptions( clientId: 'main.client.dart.js', clients: { + _code_tabs.CodeTabs: ClientTarget<_code_tabs.CodeTabs>( + 'code_tabs', + params: __code_tabsCodeTabs, + ), _telemetry_hud.TelemetryHud: ClientTarget<_telemetry_hud.TelemetryHud>( 'telemetry_hud', ), @@ -53,3 +58,10 @@ ServerOptions get defaultServerOptions => ServerOptions( ), }, ); + +Map<String, Object?> __code_tabsCodeTabs(_code_tabs.CodeTabs c) => { + 'flutter': c.flutter, + 'jaspr': c.jaspr, + 'flutterFile': c.flutterFile, + 'jasprFile': c.jasprFile, +}; diff --git a/website/pubspec.yaml b/website/pubspec.yaml index 548e55b..6812eff 100644 --- a/website/pubspec.yaml +++ b/website/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: dnd_kit_jaspr: path: ../packages/dnd_kit_jaspr jaspr: ^0.23.1 + jaspr_router: ^0.8.2 universal_web: ^1.1.1 dev_dependencies: