Skip to content

Commit a04ce91

Browse files
authored
Merge branch 'main' into engineer/doctrine-8-fs-http-docs
2 parents 6e4d0ce + 00578ff commit a04ce91

16 files changed

Lines changed: 406 additions & 193 deletions

File tree

docs/packages/dialog.md

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,42 @@ dialog.open(
167167

168168
The service supports `v-model` prop updates — if your dialog emits `update:modelValue` events, the internal state stays in sync.
169169

170+
## Accessibility — Host ARIA Attributes
171+
172+
Native `<dialog>` elements need an accessible name (and usually a description) so screen readers announce more than a generic "dialog". `dialog.open()` accepts a third options arg that applies ARIA attributes directly to the host `<dialog>` element — your inner component does not need to walk `closest('dialog')` from a template ref.
173+
174+
```typescript
175+
dialog.open(
176+
ConfirmDialog,
177+
{title: 'Delete user?', message: 'This action cannot be undone.'},
178+
{ariaLabelledBy: 'confirm-dialog-title', ariaDescribedBy: 'confirm-dialog-message'},
179+
);
180+
```
181+
182+
```vue
183+
<!-- ConfirmDialog.vue — the ids match the host attributes above -->
184+
<template>
185+
<div>
186+
<h2 id="confirm-dialog-title">{{ title }}</h2>
187+
<p id="confirm-dialog-message">{{ message }}</p>
188+
</div>
189+
</template>
190+
```
191+
192+
For dialogs without a visible title element, use `ariaLabel` instead:
193+
194+
```typescript
195+
dialog.open(
196+
IconOnlyDialog,
197+
{
198+
/**/
199+
},
200+
{ariaLabel: 'Delete confirmation'},
201+
);
202+
```
203+
204+
All three options are independent and optional — pass any combination. Options omitted leave the corresponding attribute off the `<dialog>` element entirely (no empty-string attributes).
205+
170206
## API Reference
171207

172208
### `createDialogService()`
@@ -175,12 +211,22 @@ Returns a dialog service. No parameters.
175211

176212
### Service Properties
177213

178-
| Property | Type | Description |
179-
| ---------------------------------- | ----------------------------------- | ---------------------------- |
180-
| `open(component, props)` | `(component, props) => void` | Push a dialog onto the stack |
181-
| `closeAll()` | `() => void` | Clear the entire stack |
182-
| `registerErrorMiddleware(handler)` | `(handler) => UnregisterMiddleware` | Register an error handler |
183-
| `DialogContainerComponent` | `Component` | Mount this in your app root |
214+
| Property | Type | Description |
215+
| ---------------------------------- | --------------------------------------------------------- | ---------------------------- |
216+
| `open(component, props, options?)` | `(component, props, options?: DialogOpenOptions) => void` | Push a dialog onto the stack |
217+
| `closeAll()` | `() => void` | Clear the entire stack |
218+
| `registerErrorMiddleware(handler)` | `(handler) => UnregisterMiddleware` | Register an error handler |
219+
| `DialogContainerComponent` | `Component` | Mount this in your app root |
220+
221+
### `DialogOpenOptions`
222+
223+
```typescript
224+
interface DialogOpenOptions {
225+
ariaLabel?: string; // sets aria-label on the host <dialog>
226+
ariaLabelledBy?: string; // sets aria-labelledby on the host <dialog>
227+
ariaDescribedBy?: string; // sets aria-describedby on the host <dialog>
228+
}
229+
```
184230

185231
### Error Handler Signature
186232

package-lock.json

Lines changed: 11 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/adapter-store/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-adapter-store",
3-
"version": "0.1.5",
3+
"version": "0.1.6",
44
"description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters",
55
"homepage": "https://packages.script.nl/packages/adapter-store",
66
"license": "MIT",
@@ -42,15 +42,15 @@
4242
},
4343
"devDependencies": {
4444
"@script-development/fs-helpers": "^0.1.0",
45-
"@script-development/fs-http": "^0.1.0 || ^0.2.0",
45+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0",
4646
"@script-development/fs-loading": "^0.1.0",
4747
"@script-development/fs-storage": "^0.1.0",
4848
"happy-dom": "^20.9.0",
4949
"vue": "^3.5.33"
5050
},
5151
"peerDependencies": {
5252
"@script-development/fs-helpers": "^0.1.0",
53-
"@script-development/fs-http": "^0.1.0 || ^0.2.0",
53+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0",
5454
"@script-development/fs-loading": "^0.1.0",
5555
"@script-development/fs-storage": "^0.1.0",
5656
"vue": "^3.5.33"

packages/dialog/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-dialog",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Component-agnostic dialog stack service for Vue 3 — LIFO management with error middleware, you bring the component",
55
"homepage": "https://packages.script.nl/packages/dialog",
66
"license": "MIT",

packages/dialog/src/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,20 @@ type UnregisterMiddleware = () => void;
88
/** Error handler for dialog middleware chain. Return `false` to stop propagation. */
99
export type DialogErrorHandler = (error: Error, context: {closeAll: () => void}) => boolean;
1010

11+
/** Host-level options applied to the `<dialog>` element itself, not the inner component. */
12+
export interface DialogOpenOptions {
13+
/** Sets `aria-label` on the host `<dialog>` element. */
14+
ariaLabel?: string;
15+
/** Sets `aria-labelledby` on the host `<dialog>` element. */
16+
ariaLabelledBy?: string;
17+
/** Sets `aria-describedby` on the host `<dialog>` element. */
18+
ariaDescribedBy?: string;
19+
}
20+
1121
/** Public API of a dialog service instance. */
1222
export interface DialogService {
1323
/** Open a component in a new dialog on top of the stack. */
14-
open: <C extends Component>(component: C, props: ComponentProps<C>) => void;
24+
open: <C extends Component>(component: C, props: ComponentProps<C>, options?: DialogOpenOptions) => void;
1525
/** Close all dialogs in the stack. */
1626
closeAll: () => void;
1727
/** Register an error middleware handler. Returns an unregister function. */
@@ -73,7 +83,7 @@ export const createDialogService = (): DialogService => {
7383
updateBodyScroll();
7484
};
7585

76-
const open = <C extends Component>(component: C, props: ComponentProps<C>): void => {
86+
const open = <C extends Component>(component: C, props: ComponentProps<C>, options?: DialogOpenOptions): void => {
7787
const key = `dialog-${dialogId++}`;
7888
const rawComponent = markRaw(component);
7989

@@ -87,6 +97,9 @@ export const createDialogService = (): DialogService => {
8797
{
8898
key,
8999
style: DIALOG_STYLE,
100+
'aria-label': options?.ariaLabel,
101+
'aria-labelledby': options?.ariaLabelledBy,
102+
'aria-describedby': options?.ariaDescribedBy,
90103
onCancel: (event: Event) => event.preventDefault(),
91104
onClick: (event: MouseEvent) => {
92105
if ((event.target as HTMLElement).tagName === 'DIALOG') {

packages/dialog/tests/dialog.spec.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,144 @@ describe('dialog service', () => {
817817
});
818818
});
819819

820+
describe('host ARIA attributes', () => {
821+
it('should set aria-label on the dialog element when option is provided', async () => {
822+
// Arrange
823+
const service = createDialogService();
824+
const wrapper = mount(service.DialogContainerComponent);
825+
826+
// Act
827+
service.open(TestDialogContent, {title: 'Test'}, {ariaLabel: 'Confirm action'});
828+
await nextTick();
829+
830+
// Assert
831+
const dialog = wrapper.find('dialog');
832+
expect(dialog.attributes('aria-label')).toBe('Confirm action');
833+
});
834+
835+
it('should set aria-labelledby on the dialog element when option is provided', async () => {
836+
// Arrange
837+
const service = createDialogService();
838+
const wrapper = mount(service.DialogContainerComponent);
839+
840+
// Act
841+
service.open(TestDialogContent, {title: 'Test'}, {ariaLabelledBy: 'dialog-title-id'});
842+
await nextTick();
843+
844+
// Assert
845+
const dialog = wrapper.find('dialog');
846+
expect(dialog.attributes('aria-labelledby')).toBe('dialog-title-id');
847+
});
848+
849+
it('should set aria-describedby on the dialog element when option is provided', async () => {
850+
// Arrange
851+
const service = createDialogService();
852+
const wrapper = mount(service.DialogContainerComponent);
853+
854+
// Act
855+
service.open(TestDialogContent, {title: 'Test'}, {ariaDescribedBy: 'dialog-desc-id'});
856+
await nextTick();
857+
858+
// Assert
859+
const dialog = wrapper.find('dialog');
860+
expect(dialog.attributes('aria-describedby')).toBe('dialog-desc-id');
861+
});
862+
863+
it('should set all three ARIA attributes simultaneously when all are provided', async () => {
864+
// Arrange
865+
const service = createDialogService();
866+
const wrapper = mount(service.DialogContainerComponent);
867+
868+
// Act
869+
service.open(
870+
TestDialogContent,
871+
{title: 'Test'},
872+
{ariaLabel: 'overall-label', ariaLabelledBy: 'title-ref', ariaDescribedBy: 'desc-ref'},
873+
);
874+
await nextTick();
875+
876+
// Assert — each option maps to its dedicated host attribute, not aliased
877+
const dialog = wrapper.find('dialog');
878+
expect(dialog.attributes('aria-label')).toBe('overall-label');
879+
expect(dialog.attributes('aria-labelledby')).toBe('title-ref');
880+
expect(dialog.attributes('aria-describedby')).toBe('desc-ref');
881+
});
882+
883+
it('should not set ARIA host attributes when options are omitted', async () => {
884+
// Arrange
885+
const service = createDialogService();
886+
const wrapper = mount(service.DialogContainerComponent);
887+
888+
// Act — omit the options arg entirely (covers optional-chain falsy path)
889+
service.open(TestDialogContent, {title: 'No ARIA'});
890+
await nextTick();
891+
892+
// Assert — none of the ARIA host attributes are present on the dialog element
893+
const dialog = wrapper.find('dialog');
894+
expect(dialog.attributes('aria-label')).toBeUndefined();
895+
expect(dialog.attributes('aria-labelledby')).toBeUndefined();
896+
expect(dialog.attributes('aria-describedby')).toBeUndefined();
897+
});
898+
899+
it('should not set ARIA attributes that are absent from a partial options object', async () => {
900+
// Arrange — provide only one ARIA option; the other two must remain absent.
901+
// This guards against accidentally cross-wiring options to the wrong attribute.
902+
const service = createDialogService();
903+
const wrapper = mount(service.DialogContainerComponent);
904+
905+
// Act
906+
service.open(TestDialogContent, {title: 'Test'}, {ariaLabelledBy: 'only-this'});
907+
await nextTick();
908+
909+
// Assert
910+
const dialog = wrapper.find('dialog');
911+
expect(dialog.attributes('aria-labelledby')).toBe('only-this');
912+
expect(dialog.attributes('aria-label')).toBeUndefined();
913+
expect(dialog.attributes('aria-describedby')).toBeUndefined();
914+
});
915+
916+
it('should not forward ARIA host options into the inner component props', async () => {
917+
// Arrange — host ARIA options must apply to the <dialog> element only,
918+
// never leak into the rendered component's props.
919+
const PropSpy = defineComponent({
920+
props: {
921+
title: String,
922+
onClose: Function,
923+
ariaLabel: {type: String, default: 'untouched'},
924+
ariaLabelledBy: {type: String, default: 'untouched'},
925+
ariaDescribedBy: {type: String, default: 'untouched'},
926+
},
927+
render() {
928+
return h('div', {class: 'prop-spy'}, [
929+
h('span', {class: 'spy-label'}, this.ariaLabel),
930+
h('span', {class: 'spy-labelled-by'}, this.ariaLabelledBy),
931+
h('span', {class: 'spy-described-by'}, this.ariaDescribedBy),
932+
]);
933+
},
934+
});
935+
936+
const service = createDialogService();
937+
const wrapper = mount(service.DialogContainerComponent);
938+
939+
// Act
940+
service.open(
941+
PropSpy,
942+
{title: 'Test'},
943+
{ariaLabel: 'host-label', ariaLabelledBy: 'host-title', ariaDescribedBy: 'host-desc'},
944+
);
945+
await nextTick();
946+
947+
// Assert — inner component sees its prop defaults, host attributes sit on <dialog>
948+
expect(wrapper.find('.spy-label').text()).toBe('untouched');
949+
expect(wrapper.find('.spy-labelled-by').text()).toBe('untouched');
950+
expect(wrapper.find('.spy-described-by').text()).toBe('untouched');
951+
const dialog = wrapper.find('dialog');
952+
expect(dialog.attributes('aria-label')).toBe('host-label');
953+
expect(dialog.attributes('aria-labelledby')).toBe('host-title');
954+
expect(dialog.attributes('aria-describedby')).toBe('host-desc');
955+
});
956+
});
957+
820958
describe('dialog key uniqueness', () => {
821959
it('should generate unique keys for sequential dialogs', async () => {
822960
// Arrange

packages/helpers/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@script-development/fs-helpers",
3-
"version": "0.1.1",
4-
"description": "Tree-shakeable shared utility helpers: deep copy, type guards, and case conversion",
3+
"version": "0.1.2",
4+
"description": "Tree-shakeable shared utility helpers: deep copy, type guards, case conversion, and browser download",
55
"homepage": "https://packages.script.nl/packages/helpers",
66
"license": "MIT",
77
"repository": {
@@ -43,6 +43,9 @@
4343
"dependencies": {
4444
"string-ts": "^2.3.1"
4545
},
46+
"devDependencies": {
47+
"happy-dom": "^20.9.0"
48+
},
4649
"engines": {
4750
"node": ">=24.0.0"
4851
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Triggers a browser download for a Blob.
3+
*
4+
* Creates a transient `<a>` element pointing at an object URL, dispatches a
5+
* click, then revokes the URL. The browser's download UI captures its own
6+
* reference during click(), so revoking immediately does not interrupt the
7+
* in-flight download — it simply releases the blob reference held by the URL.
8+
*
9+
* Lives in `fs-helpers` rather than `fs-http` so the HTTP factory remains a
10+
* transport-only library with no DOM coupling (fs-packages issue #59).
11+
*/
12+
export const triggerDownload = (blob: Blob, filename: string): void => {
13+
const link = document.createElement('a');
14+
link.href = URL.createObjectURL(blob);
15+
link.download = filename;
16+
link.click();
17+
URL.revokeObjectURL(link.href);
18+
};

0 commit comments

Comments
 (0)