From d4a64026a6bd476b48a29466c57fe2e6cdaa0b8c Mon Sep 17 00:00:00 2001 From: "David W. Gray" Date: Sun, 30 Nov 2025 09:08:49 -0800 Subject: [PATCH 01/10] fix(BToggle)! Remove redundant attribute cleanup & update docs for accessibility attributes on show/hide components (#2918) * refactor(BToggle)!: delegate aria-expanded cleanup to composable * docs: document how we handle accessibility attributes wrt visibility * docs: create an architecture doc covering the work in this PR and update Copilot instructions --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 110 +++- apps/docs/src/docs/components/alert.md | 4 + apps/docs/src/docs/components/collapse.md | 14 +- apps/docs/src/docs/components/dropdown.md | 2 + apps/docs/src/docs/components/modal.md | 2 + apps/docs/src/docs/components/offcanvas.md | 13 +- apps/docs/src/docs/components/popover.md | 4 + apps/docs/src/docs/components/toast.md | 2 + apps/docs/src/docs/components/tooltip.md | 4 + apps/docs/src/docs/directives/BToggle.md | 27 +- apps/docs/src/docs/reference/accessibility.md | 87 +++ .../demo/AriaRegistrationDirective.vue | 8 + .../reference/demo/AriaRegistrationManual.vue | 27 + .../demo/AriaRegistrationProgrammatic.vue | 50 ++ architecture/ARIA_VISIBILITY.md | 512 ++++++++++++++++++ .../src/directives/BToggle/index.ts | 8 +- 16 files changed, 840 insertions(+), 34 deletions(-) create mode 100644 apps/docs/src/docs/reference/demo/AriaRegistrationDirective.vue create mode 100644 apps/docs/src/docs/reference/demo/AriaRegistrationManual.vue create mode 100644 apps/docs/src/docs/reference/demo/AriaRegistrationProgrammatic.vue create mode 100644 architecture/ARIA_VISIBILITY.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ee5a8e492..d644f7c1e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,43 +5,50 @@ Always reference these instructions first and fallback to search or bash command ## Working Effectively ### Required Setup + - Install Node.js 20.x or 22.x (engine requires >=22.0.0 but 20.x works with warnings) - Install pnpm globally: `npm install -g pnpm@10.13.1` (ONLY pnpm is allowed as package manager) Use the version specified in the packageManager field of the package.json file - Clone repository and open the **root** directory (not subdirectories like packages/bootstrap-vue-next) ### Bootstrap, Build, and Test the Repository + 1. `pnpm install --ignore-scripts --frozen-lockfile` -- takes 2 seconds. Use `--ignore-scripts` to avoid docs build network issues. 2. `pnpm --filter bootstrap-vue-next run build` -- takes 27 seconds. NEVER CANCEL. Set timeout to 60+ minutes. 3. `pnpm --filter bootstrap-vue-next run test:unit:ci` -- takes 40 seconds. NEVER CANCEL. Set timeout to 60+ minutes. 4. `pnpm --filter bootstrap-vue-next run test:lint` -- takes 12 seconds. NEVER CANCEL. Set timeout to 30+ minutes. ### Build Individual Components + - Bootstrap Vue Next core package: `pnpm --filter bootstrap-vue-next run build` -- 27 seconds -- Nuxt package: `pnpm --filter @bootstrap-vue-next/nuxt run build` -- 25 seconds +- Nuxt package: `pnpm --filter @bootstrap-vue-next/nuxt run build` -- 25 seconds - Playground app: `pnpm --filter playground run build` -- 8 seconds - **NEVER** try to build docs app directly - it fails due to network connectivity (OpenCollective API) ### Development Servers -- Core package dev server: `pnpm --filter bootstrap-vue-next run dev` (runs on http://localhost:5174) -- Playground app dev server: `pnpm --filter playground run dev` (runs on http://localhost:5173) -- Docs dev server: `pnpm --filter docs run dev` (runs on http://localhost:8000) + +- Core package dev server: `pnpm --filter bootstrap-vue-next run dev` (runs on ) +- Playground app dev server: `pnpm --filter playground run dev` (runs on ) +- Docs dev server: `pnpm --filter docs run dev` (runs on ) - All dev servers: `pnpm dev` (starts all development environments in parallel) ## Validation ### Always Validate Changes + - ALWAYS run `pnpm --filter bootstrap-vue-next run test:lint` before committing (12 seconds) - ALWAYS run `pnpm --filter bootstrap-vue-next run test:unit:ci` after making changes (40 seconds) - ALWAYS run `pnpm --filter bootstrap-vue-next run build` to ensure buildability (27 seconds) - For linting fixes: `pnpm --filter bootstrap-vue-next run lint` (auto-fixes issues) ### Manual Testing Scenarios -- Test component changes using the core package dev server at http://localhost:5174 + +- Test component changes using the core package dev server at - Use `./packages/bootstrap-vue-next/src/App.vue` as a test area for changes -- Test real-world usage with the playground app at http://localhost:5173 +- Test real-world usage with the playground app at - The playground mimics user library usage but requires `pnpm build` for changes (no hot-reload) ### CI Validation + - The CI runs: build, test:lint:ci, test:unit:ci - Use `pnpm run test:ci` to run the same tests locally - NEVER CANCEL builds or tests - they are designed for long runs @@ -49,13 +56,14 @@ Always reference these instructions first and fallback to search or bash command ## Repository Structure ### Monorepo Layout -``` + +```plaintext packages/ ├── bootstrap-vue-next/ # Main Vue 3 component library └── nuxt/ # Nuxt 3 module apps/ -├── docs/ # VitePress documentation site +├── docs/ # VitePress documentation site └── playground/ # Test application for user scenarios templates/ @@ -63,6 +71,7 @@ templates/ ``` ### Key Directories + - `packages/bootstrap-vue-next/src/` - Main library source code - `packages/bootstrap-vue-next/src/components/` - Vue components - `packages/bootstrap-vue-next/src/composables/` - Vue composables @@ -71,15 +80,22 @@ templates/ - `apps/playground/src/` - User scenario testing ### Important Files + - `package.json` - Root workspace configuration - `pnpm-workspace.yaml` - Workspace package definitions - `turbo.json` - Task pipeline configuration - `vitest.workspace.mjs` - Test workspace setup - `.github/workflows/ci.yaml` - CI pipeline +### Architecture Documentation + +- `architecture/` - Technical architecture documentation + - `ARIA_VISIBILITY.md` - ARIA accessibility system for show/hide components + ## Documentation Requirements ### Component Documentation (.data.ts files) + - **CRITICAL**: When adding/modifying component props, events, or slots, ALWAYS update the corresponding `.data.ts` file in `apps/docs/src/data/components/` - Each component has a `.data.ts` file that defines: - `props`: All component properties with types, defaults, and descriptions @@ -92,20 +108,23 @@ templates/ ## Common Tasks ### Making Component Changes + 1. Edit files in `packages/bootstrap-vue-next/src/components/` -2. Test using `packages/bootstrap-vue-next/src/App.vue` +2. Test using `packages/bootstrap-vue-next/src/App.vue` 3. Run `pnpm --filter bootstrap-vue-next run dev` for hot-reload testing 4. Run `pnpm --filter bootstrap-vue-next run test:unit:ci` to validate 5. Run `pnpm --filter bootstrap-vue-next run test:lint` before committing 6. **ALWAYS update documentation** in `apps/docs/src/data/components/*.data.ts` when adding/modifying props, events, or slots ### Testing Changes End-to-End + 1. Make changes in `packages/bootstrap-vue-next/` 2. Run `pnpm --filter bootstrap-vue-next run build` 3. Test in playground: `pnpm --filter playground run dev` 4. Check real usage scenarios in the playground app ### Adding New Components + 1. Create component in `packages/bootstrap-vue-next/src/components/` 2. Add to `packages/bootstrap-vue-next/src/components/index.ts` 3. Write tests following existing patterns (see `*.spec.ts` files) @@ -116,6 +135,7 @@ templates/ - Modifying component interfaces ### Working with Styles + - Main styles: `packages/bootstrap-vue-next/src/styles/styles.scss` - Component styles are typically in the component `.vue` files - Bootstrap 5.3.x is the base CSS framework @@ -123,16 +143,18 @@ templates/ ## Timing and Performance ### Command Timing (measured) + - `pnpm install --ignore-scripts --frozen-lockfile`: ~2 seconds -- `pnpm --filter bootstrap-vue-next run build`: ~27 seconds +- `pnpm --filter bootstrap-vue-next run build`: ~27 seconds - `pnpm --filter bootstrap-vue-next run test:unit:ci`: ~40 seconds (1567 tests) - `pnpm --filter bootstrap-vue-next run test:lint`: ~12 seconds - `pnpm --filter playground run build`: ~8 seconds - `pnpm --filter @bootstrap-vue-next/nuxt run build`: ~25 seconds ### CRITICAL TIMEOUT WARNINGS + - **NEVER CANCEL** any build or test command -- Set timeouts to 60+ minutes for builds +- Set timeouts to 60+ minutes for builds - Set timeouts to 60+ minutes for test suites - Builds may take longer in CI environments - Test suites run 1500+ tests and require time @@ -140,31 +162,95 @@ templates/ ## Known Issues and Workarounds ### Network-Related Build Failures + - Docs build fails due to OpenCollective API calls: **EXPECTED** - Use `pnpm install --ignore-scripts` to skip problematic prepare scripts - Filter builds to specific packages to avoid docs: `pnpm --filter bootstrap-vue-next run build` ### Engine Version Warnings + - Repository requires Node.js >=22.0.0 but works with 20.x (shows warnings) - Warnings are safe to ignore during development ### Generated Files + - `*.timestamp-*` files are generated and should be ignored (already in .gitignore) - VitePress generates temporary data files during build ## Package Manager Rules + - **ONLY pnpm is allowed** - npm and yarn will cause errors - Use exact version `pnpm@10.13.1` for consistency - Always use `--frozen-lockfile` for reproducible installs - Use workspace filters: `--filter ` for targeted operations ## Conventional Commits + - Use conventional commit format: `feat:`, `fix:`, `docs:`, etc. - Required for automated changelog and releases - Examples: `feat: add new button variant`, `fix: resolve modal focus issue` ## Testing Architecture + - Vitest for unit testing with Vue Test Utils - 1567+ tests across components - Coverage reports available via `pnpm --filter bootstrap-vue-next run test:coverage` -- Tests use Happy DOM environment for performance \ No newline at end of file +- Tests use Happy DOM environment for performance + +## Documentation Examples + +### Demo File Format + +All demo files in `apps/docs/src/docs/*/demo/` must follow this structure: + +1. **Order**: Template first, then script, then style (if applicable) +2. **Template-Only Examples**: For simple template-only examples wrap example code in `` and `` comments +3. **Complex Examples**: Include script setup after template, using TypeScript + +**Template-only example:** + +```vue + +``` + +**Example with script:** + +```vue + + + +``` + +### Demo References in Markdown + +Use the `<<< DEMO` syntax to reference demo files: + +- **Show full file**: `<<< DEMO ./demo/MyComponent.vue{vue}` +- **Show specific section**: Use `#region name` markers in the demo file and reference with `#name` in the markdown (e.g., `#region template` is referenced as `#template`) + +### Demo File Guidelines + +- Place demo files in `apps/docs/src/docs/[category]/demo/` directory +- Name files descriptively: `ComponentFeature.vue` (e.g., `AccordionOverview.vue`, `AlertDismissible.vue`) +- Use unique IDs for all components to avoid conflicts when multiple demos render on same page +- Keep examples focused on demonstrating one feature or pattern +- Include comments for clarity when showing complex patterns diff --git a/apps/docs/src/docs/components/alert.md b/apps/docs/src/docs/components/alert.md index efcdfee37..7227e2e7a 100644 --- a/apps/docs/src/docs/components/alert.md +++ b/apps/docs/src/docs/components/alert.md @@ -68,6 +68,10 @@ The BAlert exposes four functions to manipulate the state of an active timer: `p <<< DEMO ./demo/AlertFunctions.vue +## Accessibility + +For information on managing ARIA attributes for alert triggers (when using dismissible alerts), see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. + ## Timer Props - `Immediate`: Setting this property to `false` will cause a timer to not start immediately upon render. A timer that is not started is not rendered. It must manually be started with `resume()` or `restart()`. Default is `true`. diff --git a/apps/docs/src/docs/components/collapse.md b/apps/docs/src/docs/components/collapse.md index 088eed7cc..59a25f50f 100644 --- a/apps/docs/src/docs/components/collapse.md +++ b/apps/docs/src/docs/components/collapse.md @@ -49,8 +49,6 @@ You can also pass multiple target Ids via the directive _value_ in BootstrapVueN The `header` and `footer` slots can be used to create custom toggles for your collapsible content. The default slot is used for the content to be hidden or shown. -Using the `v-b-toggle` directive to toggle the `BCollapse` will still work but the `collapsed` CSS class will no longer be applied to the element with the directive. - The following properties are available for the `header` and `footer` and `default` slots: | Property | Type | Description | @@ -81,14 +79,12 @@ These are accessed through the [template ref](https://vuejs.org/guide/essentials ## Accessibility -The `v-b-toggle` directive will automatically add the ARIA attributes `aria-controls` and -`aria-expanded` to the component that the directive appears on (as well as add the class `collapsed` -when not expanded). `aria-expanded` will reflect the state of the target `BCollapse` component, -while `aria-controls` will be set to the Id(s) of the target `BCollapse` component(s). +The `v-b-toggle` directive will automatically add the ARIA attribute `aria-controls` to the trigger +element and register it with the target `BCollapse` component. The collapse component will then +automatically manage the `aria-expanded` attribute and `collapsed` class on the trigger element to +reflect its visibility state. -If using `v-model` to set the visible state instead of the directive `v-b-toggle`, you will be -required to, on the toggle element, add the `aria-controls` and other appropriate attributes and -classes yourself. +For detailed information on managing ARIA attributes for triggers, including examples of using `v-b-toggle` with `v-model` and manual ARIA management, see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. While the `v-b-toggle` directive can be placed on almost any HTML element or Vue component, it is recommended to use a button or link (or similar component) to act as your toggler; otherwise your diff --git a/apps/docs/src/docs/components/dropdown.md b/apps/docs/src/docs/components/dropdown.md index 076bd2fc7..fef45c1a0 100644 --- a/apps/docs/src/docs/components/dropdown.md +++ b/apps/docs/src/docs/components/dropdown.md @@ -287,6 +287,8 @@ The default ARIA role is set to `menu`, but you can change this default to anoth When a menu item does not trigger navigation, it is recommended to use the `BDropdownItemButton` sub-component (which is not announced as a link) instead of `BDropdownItem` (which is presented as a link to the user). +For information on managing ARIA attributes for dropdown triggers, see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. + ### Headers and accessibility When using `BDropdownHeader` components in the dropdown menu, it is recommended to add an `id` attribute to each of the headers, and then set the `aria-describedby` attribute (set to the `id` value of the associated header) on each following dropdown items under that header. This will provide users of assistive technologies (i.e. sight-impaired users) additional context about the dropdown item: diff --git a/apps/docs/src/docs/components/modal.md b/apps/docs/src/docs/components/modal.md index 4d8bc55d4..9f894f48d 100644 --- a/apps/docs/src/docs/components/modal.md +++ b/apps/docs/src/docs/components/modal.md @@ -297,6 +297,8 @@ If you're looking for replacements for `$bvModal.msgBoxOk` and `$bvModal.msgBoxC `` provides several accessibility features, including auto focus, return focus, keyboard (tab) _focus containment_, and automated `aria-*` attributes. +For information on managing ARIA attributes for modal triggers, see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. + **Note:** The animation effect of this component is dependent on the `prefers-reduced-motion` media query. See the [reduced motion section of our accessibility documentation](/docs/reference/accessibility) for diff --git a/apps/docs/src/docs/components/offcanvas.md b/apps/docs/src/docs/components/offcanvas.md index 53bcb4eed..a57618096 100644 --- a/apps/docs/src/docs/components/offcanvas.md +++ b/apps/docs/src/docs/components/offcanvas.md @@ -150,8 +150,9 @@ elements outside of the offcanvas. ### `v-b-toggle` directive Using the [`v-b-toggle` directive](/docs/directives/BToggle) is the preferred method for _opening_ -the offcanvas, as it automatically handles applying the `aria-controls` and `aria-expanded` -accessibility attributes on the trigger element. +the offcanvas, as it automatically handles applying the `aria-controls` attribute and registering +the trigger with the offcanvas. The offcanvas will then manage the `aria-expanded` attribute and +visual state classes on the trigger element to reflect its open/closed state. The majority of examples on this page use the `v-b-toggle` directive. @@ -159,13 +160,9 @@ The majority of examples on this page use the `v-b-toggle` directive. The `v-model` reflects the current visibility state of the offcanvas. While it can be used to control the visibility state of the offcanvas, it is recommended to use the -[`v-b-toggle` directive](#v-b-toggle-directive) to _show_ the offcanvas for accessibility reasons. If -you do use the `v-model` to show the offcanvas, you should: +[`v-b-toggle` directive](#v-b-toggle-directive) to _show_ the offcanvas for accessibility reasons. -- Provide an `id` prop on the `` component -- Place the `aria-controls="id"` attribute (where `id` is the ID of the offcanvas) on the trigger element -- Set the `aria-expanded` attribute (also on the trigger element) to either the string `'true'` (if the offcanvas is open) or `'false'` (if the offcanvas is closed) -- Provide either a `title` prop or `aria-label` attribute on the `` component for screen readers +For detailed information on managing ARIA attributes when using `v-model`, including examples of programmatic trigger registration and manual ARIA management, see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. ## Events diff --git a/apps/docs/src/docs/components/popover.md b/apps/docs/src/docs/components/popover.md index 8a5b04579..27a7caca5 100644 --- a/apps/docs/src/docs/components/popover.md +++ b/apps/docs/src/docs/components/popover.md @@ -132,3 +132,7 @@ props can be used to control what's considered clipping. These are accessed through the [template ref](https://vuejs.org/guide/essentials/template-refs.html#template-refs) <<< DEMO ./demo/PopoverExposed.vue + +## Accessibility + +For information on managing ARIA attributes for popover triggers, see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. diff --git a/apps/docs/src/docs/components/toast.md b/apps/docs/src/docs/components/toast.md index 4da1d9111..a6c0cb4e7 100644 --- a/apps/docs/src/docs/components/toast.md +++ b/apps/docs/src/docs/components/toast.md @@ -79,6 +79,8 @@ Toasts are intended to be **small interruptions** to your visitors or users, so If you just need a single simple message to appear along the bottom or top of the user's window, use a fixed position `BAlert` instead. +For information on managing ARIA attributes for toast triggers, see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. + ### Accessibility tips Typically, toast messages should display one or two-line non-critical messages that **do not** diff --git a/apps/docs/src/docs/components/tooltip.md b/apps/docs/src/docs/components/tooltip.md index 8b69cadbe..3a6088a31 100644 --- a/apps/docs/src/docs/components/tooltip.md +++ b/apps/docs/src/docs/components/tooltip.md @@ -150,3 +150,7 @@ props can be used to control what's considered clipping. These are accessed through the [template ref](https://vuejs.org/guide/essentials/template-refs.html#template-refs) <<< DEMO ./demo/TooltipExposed.vue + +## Accessibility + +For information on managing ARIA attributes for tooltip triggers, see the [ARIA Trigger Registration for Component Visibility](/docs/reference/accessibility#aria-trigger-registration-for-component-visibility) section in the Accessibility reference. diff --git a/apps/docs/src/docs/directives/BToggle.md b/apps/docs/src/docs/directives/BToggle.md index da1541c27..bcad8ac0b 100644 --- a/apps/docs/src/docs/directives/BToggle.md +++ b/apps/docs/src/docs/directives/BToggle.md @@ -1,3 +1,28 @@ --- -description: 'A light-weight directive for toggling visibility state for collapses and sidebars by ID. It automatically handles the accessibility attributes on the trigger element' +description: 'A light-weight directive for toggling visibility state for collapses and sidebars by ID. It automatically sets the aria-controls attribute and registers the trigger with the target component, which then manages aria-expanded and visual state' --- + +# v-b-toggle Directive + +The `v-b-toggle` directive provides an easy way to toggle visibility of components like `BCollapse`, `BOffcanvas`, and `BModal`. + +## Accessibility + +The directive automatically handles accessibility by: + +1. Setting the `aria-controls` attribute to the ID(s) of the target component(s) +2. Registering the trigger element with the target component +3. The target component then manages: + - The `aria-expanded` attribute (set to `'true'` or `'false'` based on visibility) + - The `collapsed` and `not-collapsed` CSS classes + - Event handlers for the click event + +This separation ensures that the directive handles the initial connection while each component manages its own state attributes, providing consistent behavior across all show/hide components. + +## Usage + +See the documentation for individual components: + +- [BCollapse](/docs/components/collapse#v-b-toggle-directive) +- [BOffcanvas](/docs/components/offcanvas#v-b-toggle-directive) +- [BModal](/docs/components/modal) diff --git a/apps/docs/src/docs/reference/accessibility.md b/apps/docs/src/docs/reference/accessibility.md index 67ac5bff6..99d70fc2b 100644 --- a/apps/docs/src/docs/reference/accessibility.md +++ b/apps/docs/src/docs/reference/accessibility.md @@ -18,6 +18,93 @@ BootstrapVue's interactive components — such as modal dialogs, dropdown menus Because BootstrapVue's components are purposely designed to be fairly generic, authors may need to include further ARIA roles and attributes, as well as JavaScript behavior, to more accurately convey the precise nature and functionality of their component. This is usually noted in the documentation. +## ARIA Trigger Registration for component visibility + +Several BootstrapVueNext components (`BCollapse`, `BOffcanvas`, `BModal`, `BDropdown`, `BAlert`, `BToast`, `BPopover`) support automatic ARIA attribute management for trigger elements that control their visibility. This system ensures proper accessibility by automatically updating `aria-expanded` attributes and CSS classes (`collapsed`/`not-collapsed`) on trigger elements as the component's visibility changes. + +### How Trigger Registration Works + +Trigger registration is **opt-in** and happens through one of these methods: + +1. **Using the `v-b-toggle` directive** (recommended for most cases) +2. **Manual ARIA management** for unregistered triggers + +#### What the `v-b-toggle` Directive Does + +When you use `v-b-toggle`, the directive: + +- Sets the `aria-controls` attribute on the trigger element (pointing to the target component's ID) +- Registers the trigger element with the target component's visibility system +- Removes only the `aria-controls` attribute when the trigger unmounts + +#### What the Component's Visibility System Does + +Once a trigger is registered (via `v-b-toggle`), the component automatically: + +- Sets and maintains the `aria-expanded` attribute (`"true"` or `"false"`) based on visibility state +- Adds/removes the `collapsed` and `not-collapsed` CSS classes for visual state +- Updates these attributes and classes whenever visibility changes, regardless of the method used (`v-b-toggle` click, `v-model` change, slot functions, etc.) +- Cleans up the `aria-expanded` attribute and classes when the trigger unregisters + +This split responsibility ensures that `aria-controls` (which depends on target IDs known by the directive) is managed separately from `aria-expanded` and visual classes (which depend on the component's visibility state). + +### Method 1: Using v-b-toggle (Automatic Registration) + +The simplest approach is to use the [`v-b-toggle` directive](/docs/directives/BToggle), which automatically handles both `aria-controls` and trigger registration: + +<<< DEMO ./demo/AriaRegistrationDirective.vue#template{vue-html} + +With this approach, the button automatically receives: + +- `aria-controls="aria-demo-directive"` +- `aria-expanded="true"` or `aria-expanded="false"` (updated automatically) +- `collapsed` or `not-collapsed` CSS class (updated automatically) + +### Method 2: Using v-model with v-b-toggle + +You can combine `v-model` for programmatic control with `v-b-toggle` for automatic ARIA management. This is useful when you need to control visibility from code while still having trigger buttons with proper accessibility: + +<<< DEMO ./demo/AriaRegistrationProgrammatic.vue{vue} + +**Key Points**: + +- The `v-b-toggle` directive handles all ARIA attributes automatically +- You can still use `v-model` to control visibility programmatically +- Slot functions like `toggle`, `show`, and `hide` work seamlessly with registered triggers + +### Method 3: Manual ARIA Management + +If you don't register a trigger (via directive or programmatically), you must manually manage all ARIA attributes yourself: + +<<< DEMO ./demo/AriaRegistrationManual.vue{vue} + +This approach requires you to manually: + +- Set `aria-controls` to the component's ID +- Update `aria-expanded` based on visibility state +- Toggle `collapsed`/`not-collapsed` CSS classes + +### When to Use Each Method + +- **Use `v-b-toggle`** when you want automatic ARIA management (recommended for most cases) +- **Combine `v-b-toggle` with `v-model`** when you need both automatic ARIA management and programmatic control +- **Use manual ARIA management** only when you have specific requirements that prevent using `v-b-toggle` + +### Components Supporting Trigger Registration + +The following components support the trigger registration system: + +- [`BAlert`](/docs/components/alert) +- [`BCollapse`](/docs/components/collapse#accessibility) +- [`BDropdown`](/docs/components/dropdown) +- [`BModal`](/docs/components/modal) +- [`BOffcanvas`](/docs/components/offcanvas#accessibility) +- [`BPopover`](/docs/components/popover) +- [`BToast`](/docs/components/toast) +- [`BTooltip`](/docs/components/tooltip) + +See each component's accessibility section for specific guidance and examples. + ## Color contrast Most colors that currently make up Bootstrap V5's default palette — used throughout the framework for things such as button variations, alert variations, form validation indicators — lead to insufficient color contrast (below the recommended [WCAG 2.0 color contrast ratio of 4.5:1)](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html) when used against a light background. Authors will need to manually modify/extend these default colors to ensure adequate color contrast ratios. diff --git a/apps/docs/src/docs/reference/demo/AriaRegistrationDirective.vue b/apps/docs/src/docs/reference/demo/AriaRegistrationDirective.vue new file mode 100644 index 000000000..f45125e97 --- /dev/null +++ b/apps/docs/src/docs/reference/demo/AriaRegistrationDirective.vue @@ -0,0 +1,8 @@ + diff --git a/apps/docs/src/docs/reference/demo/AriaRegistrationManual.vue b/apps/docs/src/docs/reference/demo/AriaRegistrationManual.vue new file mode 100644 index 000000000..365c57769 --- /dev/null +++ b/apps/docs/src/docs/reference/demo/AriaRegistrationManual.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/docs/src/docs/reference/demo/AriaRegistrationProgrammatic.vue b/apps/docs/src/docs/reference/demo/AriaRegistrationProgrammatic.vue new file mode 100644 index 000000000..bb3c31841 --- /dev/null +++ b/apps/docs/src/docs/reference/demo/AriaRegistrationProgrammatic.vue @@ -0,0 +1,50 @@ + + + diff --git a/architecture/ARIA_VISIBILITY.md b/architecture/ARIA_VISIBILITY.md new file mode 100644 index 000000000..adb3c1bab --- /dev/null +++ b/architecture/ARIA_VISIBILITY.md @@ -0,0 +1,512 @@ +# ARIA Visibility Architecture + +## Overview + +Bootstrap Vue Next implements a sophisticated ARIA attribute management system for show/hide components (BCollapse, BOffcanvas, BModal, BDropdown, BAlert, BToast, BPopover, BTooltip). This system ensures proper accessibility through dynamic ARIA attributes while supporting multiple usage patterns. + +## Core Components + +### 1. Show/Hide Components + +Components that use the `useShowHide` composable: + +- **BCollapse** - Collapsible content sections +- **BOffcanvas** - Off-canvas panels +- **BModal** - Modal dialogs +- **BDropdown** - Dropdown menus +- **BAlert** - Dismissible alerts +- **BToast** - Toast notifications +- **BPopover** - Popovers +- **BTooltip** - Tooltips + +### 2. v-b-toggle Directive + +The `v-b-toggle` directive (`packages/bootstrap-vue-next/src/directives/BToggle/index.ts`) provides automatic trigger registration and relationship mapping. + +### 3. useShowHide Composable + +The `useShowHide` composable (`packages/bootstrap-vue-next/src/composables/useShowHide.ts`) manages visibility state and dynamic ARIA attributes for registered triggers. + +## Architecture Principles + +### Trigger Registration System + +The ARIA management is **opt-in** through a trigger registration system: + +**Registration happens when:** + +- Using `v-b-toggle` directive (automatic) +- Using slot functions (`toggle`, `show`, `hide`) which leverage the registration system + +**Unregistered triggers:** + +- Must manually manage all ARIA attributes +- Do NOT receive automatic updates from the composable + +### Division of Responsibilities + +The architecture splits ARIA attribute management based on **static vs dynamic** characteristics: + +#### Static Attributes (v-b-toggle Directive) + +**Managed by:** `v-b-toggle` directive + +**Attributes:** + +- `aria-controls` - Lists the IDs of controlled elements + +**Rationale:** + +- The directive knows target IDs from its binding (modifiers, arg, value, href) +- Supports multiple targets: `v-b-toggle.target1.target2.target3` +- This relationship is **static** - it doesn't change with visibility state +- Only the directive layer has access to this binding information +- Set once on mount/update, removed on unmount + +**Example:** + +```html + +Toggle +``` + +#### Dynamic Attributes (useShowHide Composable) + +**Managed by:** `useShowHide` composable (for registered triggers only) + +**Attributes:** + +- `aria-expanded` - Current visibility state ('true' or 'false') +- CSS classes - `collapsed` / `not-collapsed` visual state indicators + +**Rationale:** + +- These attributes **change dynamically** when visibility changes +- The composable watches `modelValue` and knows current state +- Must update **all registered triggers** when visibility changes +- Works regardless of HOW visibility changed (v-b-toggle click, v-model update, slot function, programmatic call) +- Centralized state management ensures consistency + +**Example:** + +```html + + +Toggle +``` + +### Why Split Responsibilities? + +The division is based on **information ownership** and **lifecycle management**: + +1. **Information Ownership:** + - Directive = knows target IDs, sets static relationships + - Composable = knows visibility state, manages dynamic attributes + +2. **Lifecycle:** + - `aria-controls` = set once, static for component lifetime + - `aria-expanded` & classes = updated on every visibility change + +3. **Multi-Trigger Support:** + - One component can have multiple registered triggers + - All triggers must update when visibility changes + - Composable's watch mechanism handles this automatically + +4. **Decoupling:** + - Directive doesn't need to know visibility state + - Composable doesn't need to know target IDs + - Each layer has single, focused responsibility + +## Data Flow + +### Registration Flow + +``` +User adds v-b-toggle directive to button + ↓ +Directive mounts and calls handleUpdate() + ↓ +1. Parses target IDs from binding +2. Sets aria-controls="target-id" on button +3. Calls component.registerTrigger('click', buttonElement) + ↓ +useShowHide.registerTrigger() executes: + ↓ +1. Adds buttonElement to triggerRegistry[] +2. Adds 'click' event listener +3. Calls checkVisibility(buttonElement) + ↓ +checkVisibility() sets initial state: + ↓ +1. Sets aria-expanded="false" (or "true" if visible) +2. Adds class="collapsed" (or "not-collapsed") +``` + +### Visibility Change Flow + +``` +Visibility changes via ANY method: + - User clicks v-b-toggle button + - v-model updates programmatically + - Slot function called (hide/show/toggle) + - Direct method call via template ref + ↓ +modelValue updates in component + ↓ +useShowHide watch() triggers: + ↓ +watch(modelValue, () => { + triggerRegistry.forEach((t) => { + checkVisibility(t.el) + }) +}) + ↓ +checkVisibility() updates EACH registered trigger: + ↓ +1. Sets aria-expanded="true" or "false" +2. Toggles collapsed/not-collapsed classes + ↓ +ALL registered triggers now reflect current state +``` + +### Unregistration Flow + +``` +Directive unmounts + ↓ +handleUnmount() executes: + ↓ +1. Calls component.unregisterTrigger('click', buttonElement, true) +2. Removes aria-controls from button + ↓ +useShowHide.unregisterTrigger(clean=true) executes: + ↓ +1. Removes buttonElement from triggerRegistry[] +2. Removes 'click' event listener +3. Removes aria-expanded attribute +4. Removes collapsed/not-collapsed classes +``` + +## Usage Patterns + +### Pattern 1: v-b-toggle Directive (Automatic) + +**Use when:** You want automatic ARIA management with minimal code + +**Example:** + +```vue + +``` + +**What happens:** + +- ✅ `aria-controls` - Set by directive +- ✅ `aria-expanded` - Managed by composable +- ✅ `collapsed`/`not-collapsed` classes - Managed by composable +- ✅ Click handler - Added by composable +- ✅ Automatic updates - All triggers update on visibility change + +### Pattern 2: v-b-toggle + v-model (Hybrid) + +**Use when:** You need both automatic ARIA and programmatic control + +**Example:** + +```vue + + + +``` + +**What happens:** + +- ✅ First button gets automatic ARIA management (registered via v-b-toggle) +- ✅ First button updates when second button closes collapse +- ✅ Programmatic control via v-model works seamlessly +- ❌ Second button has NO ARIA attributes (not registered) + +### Pattern 3: v-model with Slot Functions + +**Use when:** You want programmatic control with ARIA managed via slots + +**Example:** + +```vue + + + +``` + +**What happens:** + +- ✅ Slot functions work with v-model +- ✅ Button can use slot function for accessibility +- ℹ️ For external triggers, still need v-b-toggle or manual ARIA + +### Pattern 4: Manual ARIA Management + +**Use when:** You're not using v-b-toggle and don't register triggers + +**Example:** + +```vue + + + +``` + +**What happens:** + +- ❌ No automatic ARIA management +- ✅ Full manual control +- ✅ All ARIA attributes on trigger element +- ⚠️ Developer responsible for keeping attributes in sync + +## Component Internals + +### useShowHide Key Functions + +#### registerTrigger(trigger: string, el: Element) + +```typescript +const registerTrigger = (trigger: string, el: Element) => { + triggerRegistry.push({trigger, el}) + el.addEventListener(trigger, triggerToggle) + checkVisibility(el) // Sets initial state +} +``` + +**Purpose:** Adds trigger to registry and sets initial ARIA state + +**Called by:** + +- v-b-toggle directive (internal use only) + +#### unregisterTrigger(trigger: string, el: Element, clean = true) + +```typescript +const unregisterTrigger = (trigger: string, el: Element, clean = true) => { + const idx = triggerRegistry.findIndex((t) => t?.trigger === trigger && t.el === el) + if (idx > -1) { + triggerRegistry.splice(idx, 1) + el.removeEventListener(trigger, triggerToggle) + if (clean) { + el.removeAttribute('aria-expanded') + el.classList.remove('collapsed') + el.classList.remove('not-collapsed') + } + } +} +``` + +**Purpose:** Removes trigger from registry and optionally cleans attributes + +**Parameters:** + +- `clean=true` - Remove aria-expanded and classes (default) +- `clean=false` - Preserve attributes (used during updates before re-registration) + +#### checkVisibility(el: Element) + +```typescript +const checkVisibility = (el: Element) => { + el.setAttribute('aria-expanded', modelValue.value ? 'true' : 'false') + el.classList.toggle('collapsed', !modelValue.value) + el.classList.toggle('not-collapsed', !!modelValue.value) +} +``` + +**Purpose:** Updates ARIA attributes and classes based on current visibility state + +**Called by:** + +- `registerTrigger()` - Sets initial state +- `watch(modelValue)` - Updates all registered triggers on visibility change + +#### Watch Mechanism + +```typescript +watch(modelValue, () => { + triggerRegistry.forEach((t) => { + checkVisibility(t.el) + }) +}) +``` + +**Purpose:** Ensures ALL registered triggers stay in sync when visibility changes + +**Why it works for all visibility sources:** + +- v-b-toggle click → updates modelValue → triggers watch +- v-model update → updates modelValue → triggers watch +- Slot function call → updates modelValue → triggers watch +- Direct method call → updates modelValue → triggers watch + +## Design Benefits + +### 1. Separation of Concerns + +- **Directive:** Relationship mapping (aria-controls) +- **Composable:** State management (aria-expanded, classes) +- Clear boundaries between static and dynamic concerns + +### 2. Consistency + +- All show/hide components use the same system +- Predictable behavior across the library +- Single source of truth for ARIA state + +### 3. Flexibility + +- Supports multiple usage patterns +- Works with v-model, directives, slots, and programmatic control +- Opt-in system doesn't force users into one approach + +### 4. Maintainability + +- Centralized ARIA logic in composable +- Changes to ARIA behavior only require updating one place +- Clear responsibilities make debugging easier + +### 5. Accessibility + +- Automatic ARIA management reduces developer burden +- Ensures all registered triggers stay in sync +- Prevents common accessibility mistakes + +### 6. Performance + +- Efficient registry-based system +- Only updates registered triggers +- Single watch handles all visibility changes + +## Common Patterns in Components + +### BAccordionItem - Internal ARIA Management + +```vue + +``` + +**Why manual?** The accordion button is internal to the component and has specific styling/behavior needs. Using the registry system would add unnecessary complexity. + +### BDropdown - Split Button Handling + +```vue + + + +``` + +**Why manual?** Different buttons need different aria-expanded values based on split mode. Template-based management is clearer than programmatic registration. + +## Testing Considerations + +### Unit Tests + +**v-b-toggle directive:** + +- Sets `aria-controls` correctly +- Registers trigger with component +- Only removes `aria-controls` on unmount +- Does NOT manage `aria-expanded` or classes + +**useShowHide composable:** + +- `registerTrigger` sets initial `aria-expanded` and classes +- `unregisterTrigger(clean=true)` removes attributes +- `unregisterTrigger(clean=false)` preserves attributes +- `watch(modelValue)` updates all registered triggers + +### Integration Tests + +- Multiple triggers for single component +- v-model updates affect registered triggers +- Rapid mount/unmount cycles +- Mixed usage patterns (v-b-toggle + v-model) + +## Migration Notes + +### From Bootstrap Vue 2 + +Bootstrap Vue Next improves ARIA management through: + +1. **Automatic registration system** - v-b-toggle handles ARIA automatically +2. **Multi-trigger support** - One component can have multiple registered triggers +3. **Consistent updates** - All triggers update regardless of visibility source +4. **Better separation** - Clear division between static (aria-controls) and dynamic (aria-expanded) attributes + +### Best Practices + +1. **Prefer v-b-toggle** for external triggers - Gets you automatic ARIA management +2. **Use v-model** when you need programmatic control - Works seamlessly with v-b-toggle +3. **Manual ARIA only when necessary** - When not using v-b-toggle and not registering triggers +4. **Don't mix approaches on same trigger** - Choose either automatic (v-b-toggle) or manual, not both +5. **Test with screen readers** - Verify ARIA attributes work correctly in practice + +## Future Considerations + +### Potential Enhancements + +1. **Exposed registerTrigger API** - Allow programmatic registration without v-b-toggle +2. **ARIA live regions** - Enhanced announcements for state changes +3. **Keyboard navigation** - Arrow key support for grouped show/hide components +4. **Focus management** - Automatic focus handling on show/hide + +### Non-Goals + +- **Automatic registration of all buttons** - Opt-in system is intentional +- **ARIA on target elements** - Target elements (BCollapse, etc.) don't need aria-expanded +- **Complex ARIA patterns** - Keep it simple; focus on core show/hide accessibility diff --git a/packages/bootstrap-vue-next/src/directives/BToggle/index.ts b/packages/bootstrap-vue-next/src/directives/BToggle/index.ts index 54d73d311..6e4e94161 100644 --- a/packages/bootstrap-vue-next/src/directives/BToggle/index.ts +++ b/packages/bootstrap-vue-next/src/directives/BToggle/index.ts @@ -119,13 +119,13 @@ const handleUnmount = ( if (!showHide) { return } - toValue(showHide).unregisterTrigger('click', el, false) + // Pass clean=true to let the composable handle cleanup of aria-expanded and classes + toValue(showHide).unregisterTrigger('click', el, true) }) + // Only remove what the directive manages (aria-controls) + // aria-expanded and classes are managed by useShowHide composable el.removeAttribute('aria-controls') - el.removeAttribute('aria-expanded') - el.classList.remove('collapsed') - el.classList.remove('not-collapsed') delete (el as HTMLElement).dataset.bvtoggle } From bae393217317fe9f2e2b09efb488da152c472aec Mon Sep 17 00:00:00 2001 From: "David W. Gray" Date: Mon, 1 Dec 2025 14:23:10 -0800 Subject: [PATCH 02/10] fix(BTable)!: make sort icons background images to avoid wrapping & remove relevant slots --- apps/docs/src/data/components/table.data.ts | 6 + .../docs/components/demo/TableSortIcons.vue | 41 +++++++ apps/docs/src/docs/components/table.md | 33 +++--- apps/docs/src/docs/migration-guide.md | 2 - .../src/components/BTable/BTable.vue | 58 ++-------- .../src/components/BTable/_table.scss | 89 +++++++++++++++ .../src/components/BTable/table.spec.ts | 104 ++++++++++++++++-- .../src/types/ComponentProps.ts | 2 +- 8 files changed, 260 insertions(+), 75 deletions(-) create mode 100644 apps/docs/src/docs/components/demo/TableSortIcons.vue diff --git a/apps/docs/src/data/components/table.data.ts b/apps/docs/src/data/components/table.data.ts index fbb9cdf74..e0bab3fa2 100644 --- a/apps/docs/src/data/components/table.data.ts +++ b/apps/docs/src/data/components/table.data.ts @@ -706,6 +706,12 @@ export default { type: 'boolean', default: false, }, + sortIconLeft: { + type: 'boolean', + default: false, + description: + 'Position the sort icon on the left side of the table header cell instead of the right', + }, perPage: { type: 'Numberish', default: Number.POSITIVE_INFINITY, diff --git a/apps/docs/src/docs/components/demo/TableSortIcons.vue b/apps/docs/src/docs/components/demo/TableSortIcons.vue new file mode 100644 index 000000000..a06d94d9e --- /dev/null +++ b/apps/docs/src/docs/components/demo/TableSortIcons.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/docs/src/docs/components/table.md b/apps/docs/src/docs/components/table.md index 8a25df152..7da017205 100644 --- a/apps/docs/src/docs/components/table.md +++ b/apps/docs/src/docs/components/table.md @@ -774,6 +774,16 @@ any of the other options of [`localeCompare`](https://developer.mozilla.org/en-U <<< DEMO ./demo/TableSortByCustom.vue +### Sort Icon Positioning + +By default, sort icons are displayed at the far right edge of sortable column headers using CSS background images. This prevents the icons from wrapping to a new line and ensures consistent appearance. + +You can position the sort icon on the left side of the header cell instead by setting the `sort-icon-left` prop to `true`. + +<<< DEMO ./demo/TableSortIcons.vue + +**Note:** For more advanced control over header layout and sorting behavior, you can use [scoped slots for header and footer rendering](#header-and-footer-custom-rendering-via-scoped-slots). The scoped slots provide access to `selectAllRows` and `clearSelected` functions for managing row selection, and allow you to create completely custom header layouts while maintaining sorting functionality through the `head-clicked` event. + ## Filtering Filtering, when used, is applied by default to the **original items** array data. `Btable` provides @@ -872,13 +882,13 @@ The provider function is called with the following signature: The `ctx` is the context object associated with the table state, and contains the following properties: -| Property | Type | Description | -| ------------- | -------------------------------- | --------------------------------------------------------------------------------- | -| `currentPage` | `number` | The current page number (starting from 1, the value of the `current-page` prop) | -| `perPage` | `number` | The maximum number of rows per page to display (the value of the `per-page` prop) | -| `filter` | `string \| undefined` | The value of the `filter` prop | -| `sortBy` | `BTableSortBy[] \| undefined` | The current column key being sorted, or an empty string if not sorting | -| `signal` | `AbortSignal` | An AbortSignal that can be used to cancel the request when a new provider call is triggered | +| Property | Type | Description | +| ------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `currentPage` | `number` | The current page number (starting from 1, the value of the `current-page` prop) | +| `perPage` | `number` | The maximum number of rows per page to display (the value of the `per-page` prop) | +| `filter` | `string \| undefined` | The value of the `filter` prop | +| `sortBy` | `BTableSortBy[] \| undefined` | The current sort state as a `BTableSortBy[]` (array of `{ key, order }` entries), or `undefined` when unsorted | +| `signal` | `AbortSignal` | An AbortSignal that can be used to cancel the request when a new provider call is triggered | ### Debouncing Provider Calls @@ -890,12 +900,7 @@ To avoid excessive provider calls (e.g., when typing rapidly in a filter), you c Example with debouncing: ```vue - + ``` ### Handling Request Cancellation @@ -922,7 +927,7 @@ const myProvider = async (ctx: BTableProviderContext) => { // Perform your async operation resolve(items) }, 1000) - + // Clean up when aborted ctx.signal.addEventListener('abort', () => { clearTimeout(timeout) diff --git a/apps/docs/src/docs/migration-guide.md b/apps/docs/src/docs/migration-guide.md index 582655e85..5311c77a6 100644 --- a/apps/docs/src/docs/migration-guide.md +++ b/apps/docs/src/docs/migration-guide.md @@ -978,8 +978,6 @@ If you find a need for the other types (Avatar or Input), please open an issue o ### BTable - - See the [v-html](#v-html) section for information on deprecation of the `html` prop. The slot `emptyfiltered` has been renamed to `empty-filtered` for consistency. diff --git a/packages/bootstrap-vue-next/src/components/BTable/BTable.vue b/packages/bootstrap-vue-next/src/components/BTable/BTable.vue index 3b23e0d7b..45f5fed98 100644 --- a/packages/bootstrap-vue-next/src/components/BTable/BTable.vue +++ b/packages/bootstrap-vue-next/src/components/BTable/BTable.vue @@ -74,53 +74,6 @@ > {{ getTableFieldHeadLabel(field) }} - + + diff --git a/apps/docs/src/docs/components/demo/FormFileDirectory.vue b/apps/docs/src/docs/components/demo/FormFileDirectory.vue new file mode 100644 index 000000000..3b91219f7 --- /dev/null +++ b/apps/docs/src/docs/components/demo/FormFileDirectory.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/docs/src/docs/components/demo/FormFileDirectoryMigration.vue b/apps/docs/src/docs/components/demo/FormFileDirectoryMigration.vue new file mode 100644 index 000000000..fc36658df --- /dev/null +++ b/apps/docs/src/docs/components/demo/FormFileDirectoryMigration.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/docs/src/docs/components/demo/FormFileDirectoryPathExample.ts b/apps/docs/src/docs/components/demo/FormFileDirectoryPathExample.ts new file mode 100644 index 000000000..24ceae096 --- /dev/null +++ b/apps/docs/src/docs/components/demo/FormFileDirectoryPathExample.ts @@ -0,0 +1,7 @@ +// Example: After selecting a directory with BFormFile +const files: File[] = [] // Your selected files from v-model + +files.forEach((file: File) => { + console.log(file.name) // "helpers.ts" + console.log(file.webkitRelativePath) // "src/utils/helpers.ts" +}) diff --git a/apps/docs/src/docs/components/demo/FormFileDropPlaceholder.vue b/apps/docs/src/docs/components/demo/FormFileDropPlaceholder.vue new file mode 100644 index 000000000..59bb16001 --- /dev/null +++ b/apps/docs/src/docs/components/demo/FormFileDropPlaceholder.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/docs/src/docs/components/demo/FormFileFormatter.vue b/apps/docs/src/docs/components/demo/FormFileFormatter.vue new file mode 100644 index 000000000..96d4d4865 --- /dev/null +++ b/apps/docs/src/docs/components/demo/FormFileFormatter.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/docs/src/docs/components/demo/FormFilePlain.vue b/apps/docs/src/docs/components/demo/FormFilePlain.vue new file mode 100644 index 000000000..3771feb36 --- /dev/null +++ b/apps/docs/src/docs/components/demo/FormFilePlain.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/docs/src/docs/components/form-file.md b/apps/docs/src/docs/components/form-file.md index 56a92a3ff..b18f7dc7a 100644 --- a/apps/docs/src/docs/components/form-file.md +++ b/apps/docs/src/docs/components/form-file.md @@ -2,9 +2,16 @@ description: 'File input control that supports single and multiple file modes, drag and drop, file type restrictions, and directory selection with contextual state feedback.' --- - -The current variation is subject to change pre v1.0. The implementation may change to become closer to the Bootstrap-vue implementation based on feedback vote here - +## Overview + +BFormFile provides a customized, cross-browser consistent file input control with support for: + +- Single and multiple file selection +- Drag and drop file upload +- Directory selection (browser support required) +- File type filtering via accept attribute +- Custom text and placeholders +- Bootstrap validation states ## Single File Mode @@ -42,15 +49,62 @@ You can add a label above the input by using the `label` prop or the `label` slo <<< DEMO ./demo/FormFileLabel.vue#template{vue-html} +## Customizing Text and Placeholders + +### Browse Button Text + +Customize the browse button text using the `browseText` prop (custom mode only): + +<<< DEMO ./demo/FormFileCustomText.vue + +### Drop Zone Placeholders + +Customize the placeholder text shown in different states (custom mode only): + +<<< DEMO ./demo/FormFileDropPlaceholder.vue + +## File Name Formatting + +Use the `fileNameFormatter` prop to customize how selected file names are displayed (custom mode only): + +<<< DEMO ./demo/FormFileFormatter.vue + +## Plain Mode + +Use the `plain` prop to render a native HTML file input without custom styling. This provides 100% backward compatibility with the original implementation: + +<<< DEMO ./demo/FormFilePlain.vue + + + Plain mode uses the native browser file input, which has limited styling options but provides maximum compatibility. Custom mode (default) provides drag-and-drop support and better visual customization. + + ## Directory Mode -By adding the `directory` prop, a user can select directories instead of files +By adding the `directory` prop, a user can select directories instead of files. - Directory mode is a non-standard attribute in the HTML spec. All major browsers have chosen too support it, but it may not function correctly for browsers that have chosen not to implement it. Use with caution + Directory mode uses the non-standard `webkitdirectory` attribute. Supported browsers include Chrome, Edge, Safari, Opera, and Firefox (desktop). Mobile browser support varies. The component gracefully degrades to standard file selection if unsupported. Use with caution in production environments requiring broad compatibility. -### Example to be Written +<<< DEMO ./demo/FormFileDirectory.vue + +### Accessing File Paths + +When using `directory` mode, each `File` object includes the standard `webkitRelativePath` property containing the relative path from the selected directory root: + +<<< FRAGMENT ./demo/FormFileDirectoryPathExample.ts + +The `webkitRelativePath` property allows you to: + +- Display the full file path to users +- Reconstruct directory structure in your application +- Group files by folder +- Preserve directory hierarchy when processing files + +::: tip Browser Compatibility +The `webkitRelativePath` property is available in all browsers that support directory selection. It's part of the standard File API when using the `webkitdirectory` attribute. +::: ## Autofocus @@ -64,14 +118,24 @@ You can use the `state` prop to provide visual feedback on the state of the inpu <<< DEMO ./demo/FormFileState.vue#template{vue-html} -## Modifying the file selection +## Important Notes + +### Prop Reactivity + +The `accept`, `multiple`, and `directory` props support runtime changes. The file dialog and drop zone will automatically reflect updated values when these props change. + +::: info Drop Zone Multiple Limitation +The drop zone's `multiple` validation is set at component initialization and will not update if the `multiple` prop changes at runtime. However, the actual file handling logic respects the current `multiple` prop value, so files will be processed correctly. If you need the drop zone validation to update, remount the component with a new `key` attribute when changing the `multiple` prop. +::: + +### Modifying the file selection With inputs that are of type `file`, the value is strictly `uni-directional`. Meaning that you cannot change the value of the input via JavaScript. You can change the value of the `v-model`, and this will work for an "outside view", however, the actual `input` element will not have its [FileList](https://developer.mozilla.org/en-US/docs/Web/API/FileList) changed. This is for security reasons as a malicious script could attempt to read and steal documents ## Exposed functions -The BFormFile exposes functions to control the component: `focus(), blur(), reset()`. These are accessed through the [template ref](https://vuejs.org/guide/essentials/template-refs.html#template-refs). +The BFormFile exposes functions to control the component: `focus()`, `blur()`, `reset()`. These are accessed through the [template ref](https://vuejs.org/guide/essentials/template-refs.html#template-refs). -1. Focus: focuses the file input -2. Blur: blurs the file input focus -3. Reset: Resets the file selection so that no file is selected +1. `focus()`: Focuses the file input (or browse button in custom mode) +2. `blur()`: Blurs the file input focus +3. `reset()`: Resets the file selection so that no file is selected diff --git a/apps/docs/src/docs/demo/FormFileDirectoryBSV.vue b/apps/docs/src/docs/demo/FormFileDirectoryBSV.vue new file mode 100644 index 000000000..d3c446f96 --- /dev/null +++ b/apps/docs/src/docs/demo/FormFileDirectoryBSV.vue @@ -0,0 +1,15 @@ + + + diff --git a/apps/docs/src/docs/demo/FormFileDirectoryBSVN.vue b/apps/docs/src/docs/demo/FormFileDirectoryBSVN.vue new file mode 100644 index 000000000..3b6fe2772 --- /dev/null +++ b/apps/docs/src/docs/demo/FormFileDirectoryBSVN.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/docs/src/docs/demo/FormFileDirectoryPaths.vue b/apps/docs/src/docs/demo/FormFileDirectoryPaths.vue new file mode 100644 index 000000000..1c136c581 --- /dev/null +++ b/apps/docs/src/docs/demo/FormFileDirectoryPaths.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/docs/src/docs/migration-guide.md b/apps/docs/src/docs/migration-guide.md index 5311c77a6..76fda6b88 100644 --- a/apps/docs/src/docs/migration-guide.md +++ b/apps/docs/src/docs/migration-guide.md @@ -441,7 +441,33 @@ See [BForm Components](/docs/components/form-checkbox) ### BFormFile - +BootstrapVueNext has completely rewritten `BFormFile` using [VueUse](https://vueuse.org/) composables (`useFileDialog` and `useDropZone`), resulting in a more modern, maintainable implementation. + +#### Directory Mode + +The `noTraverse` prop has been **removed**. BootstrapVueNext directory mode always returns files as a flat array, which matches the behavior of the browser's native file input with the `webkitdirectory` attribute. + +When using `directory` mode, each `File` object includes the standard `webkitRelativePath` property containing the relative path from the selected directory root. This is a native browser property that's automatically available when using directory selection. This has replaced the deprecated `$path` property. + +**Example:** + +<<< FRAGMENT ./components/demo/FormFileDirectoryPathExample.ts + +The `webkitRelativePath` property allows you to reconstruct directory structure or group files by folder as needed. + +**BootstrapVue code:** + +<<< FRAGMENT ./demo/FormFileDirectoryBSV.vue#template{vue-html} + +**BootstrapVueNext equivalent:** + +<<< DEMO ./components/demo/FormFileDirectoryMigration.vue + +#### Drop Placeholder Slot + +The `drop-placeholder` slot no longer receives a `dropAllowed` scope property. VueUse's `useDropZone` handles file type validation internally, and we don't have access to its validation state. The slot now simply displays the drop placeholder text. + +The `noDropPlaceholder` prop has been removed as it was only used when `dropAllowed` was `false`, which never occurred. ### BFormGroup diff --git a/apps/docs/src/vite-env.d.ts b/apps/docs/src/vite-env.d.ts index 11f02fe2a..8f990b984 100644 --- a/apps/docs/src/vite-env.d.ts +++ b/apps/docs/src/vite-env.d.ts @@ -1 +1,15 @@ /// + +// Augment File interface to include $path property from webkitRelativePath +// Used by BFormFile in directory mode +declare global { + interface File { + /** + * Directory path of the file (derived from webkitRelativePath) + * Only available when selecting files in directory mode + */ + $path?: string + } +} + +export {} diff --git a/architecture/BFORMFILE.md b/architecture/BFORMFILE.md new file mode 100644 index 000000000..702cfbf32 --- /dev/null +++ b/architecture/BFORMFILE.md @@ -0,0 +1,355 @@ +# BFormFile Architecture + +## Overview + +`BFormFile` is a file input component that wraps the native HTML `` element with Bootstrap styling and Vue reactivity. The component leverages VueUse composables for robust file selection and drag-and-drop functionality. + +## Core Design Principles + +1. **Composable-First**: Use battle-tested VueUse composables rather than reimplementing file handling +2. **Native HTML Foundation**: Leverage native `` capabilities with progressive enhancement +3. **Accessibility**: Maintain proper ARIA attributes and semantic HTML structure +4. **Bootstrap Integration**: Apply Bootstrap form control classes and styling conventions +5. **Vue Reactivity**: Use Vue's reactivity system for file state management + +## Architecture + +### Component Structure + +```text +BFormFile +├── Wrapper
+│ ├── Class/Style attributes +│ └── Drop zone functionality (when not disabled/plain) +│ +├── Label