@furystack/shades-common-components 14.0.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/esm/components/accordion/accordion-item.d.ts.map +1 -1
- package/esm/components/accordion/accordion-item.js +6 -9
- package/esm/components/accordion/accordion-item.js.map +1 -1
- package/esm/components/accordion/accordion.d.ts +7 -0
- package/esm/components/accordion/accordion.d.ts.map +1 -1
- package/esm/components/accordion/accordion.js +4 -1
- package/esm/components/accordion/accordion.js.map +1 -1
- package/esm/components/accordion/accordion.spec.js +91 -50
- package/esm/components/accordion/accordion.spec.js.map +1 -1
- package/esm/components/carousel.js +1 -1
- package/esm/components/carousel.js.map +1 -1
- package/esm/components/chip.d.ts.map +1 -1
- package/esm/components/chip.js +4 -2
- package/esm/components/chip.js.map +1 -1
- package/esm/components/chip.spec.js +42 -0
- package/esm/components/chip.spec.js.map +1 -1
- package/esm/components/command-palette/index.d.ts.map +1 -1
- package/esm/components/command-palette/index.js +14 -1
- package/esm/components/command-palette/index.js.map +1 -1
- package/esm/components/command-palette/index.spec.js +78 -33
- package/esm/components/command-palette/index.spec.js.map +1 -1
- package/esm/components/data-grid/data-grid-row.d.ts.map +1 -1
- package/esm/components/data-grid/data-grid-row.js +18 -2
- package/esm/components/data-grid/data-grid-row.js.map +1 -1
- package/esm/components/data-grid/data-grid.d.ts +7 -0
- package/esm/components/data-grid/data-grid.d.ts.map +1 -1
- package/esm/components/data-grid/data-grid.js +28 -10
- package/esm/components/data-grid/data-grid.js.map +1 -1
- package/esm/components/data-grid/data-grid.spec.js +114 -34
- package/esm/components/data-grid/data-grid.spec.js.map +1 -1
- package/esm/components/data-grid/selection-cell.d.ts.map +1 -1
- package/esm/components/data-grid/selection-cell.js +1 -1
- package/esm/components/data-grid/selection-cell.js.map +1 -1
- package/esm/components/dialog.d.ts +11 -0
- package/esm/components/dialog.d.ts.map +1 -1
- package/esm/components/dialog.js +2 -2
- package/esm/components/dialog.js.map +1 -1
- package/esm/components/dialog.spec.js +54 -2
- package/esm/components/dialog.spec.js.map +1 -1
- package/esm/components/dropdown.d.ts.map +1 -1
- package/esm/components/dropdown.js +1 -1
- package/esm/components/dropdown.js.map +1 -1
- package/esm/components/dropdown.spec.js +8 -0
- package/esm/components/dropdown.spec.js.map +1 -1
- package/esm/components/image.d.ts.map +1 -1
- package/esm/components/image.js +15 -6
- package/esm/components/image.js.map +1 -1
- package/esm/components/image.spec.js +60 -0
- package/esm/components/image.spec.js.map +1 -1
- package/esm/components/inputs/checkbox.d.ts.map +1 -1
- package/esm/components/inputs/checkbox.js +1 -0
- package/esm/components/inputs/checkbox.js.map +1 -1
- package/esm/components/inputs/radio.d.ts.map +1 -1
- package/esm/components/inputs/radio.js +1 -0
- package/esm/components/inputs/radio.js.map +1 -1
- package/esm/components/inputs/slider.d.ts.map +1 -1
- package/esm/components/inputs/slider.js +1 -0
- package/esm/components/inputs/slider.js.map +1 -1
- package/esm/components/inputs/switch.d.ts.map +1 -1
- package/esm/components/inputs/switch.js +1 -0
- package/esm/components/inputs/switch.js.map +1 -1
- package/esm/components/list/list-item.d.ts.map +1 -1
- package/esm/components/list/list-item.js +21 -5
- package/esm/components/list/list-item.js.map +1 -1
- package/esm/components/list/list.d.ts +7 -0
- package/esm/components/list/list.d.ts.map +1 -1
- package/esm/components/list/list.js +28 -8
- package/esm/components/list/list.js.map +1 -1
- package/esm/components/list/list.spec.js +117 -23
- package/esm/components/list/list.spec.js.map +1 -1
- package/esm/components/markdown/markdown-display.d.ts.map +1 -1
- package/esm/components/markdown/markdown-display.js +11 -1
- package/esm/components/markdown/markdown-display.js.map +1 -1
- package/esm/components/markdown/markdown-display.spec.js +97 -0
- package/esm/components/markdown/markdown-display.spec.js.map +1 -1
- package/esm/components/markdown/markdown-editor.spec.js +87 -0
- package/esm/components/markdown/markdown-editor.spec.js.map +1 -1
- package/esm/components/menu/menu.js +1 -1
- package/esm/components/menu/menu.js.map +1 -1
- package/esm/components/modal.d.ts +10 -0
- package/esm/components/modal.d.ts.map +1 -1
- package/esm/components/modal.js +24 -4
- package/esm/components/modal.js.map +1 -1
- package/esm/components/modal.spec.js +86 -1
- package/esm/components/modal.spec.js.map +1 -1
- package/esm/components/page-layout/index.js +1 -1
- package/esm/components/page-layout/index.js.map +1 -1
- package/esm/components/page-layout/index.spec.js +14 -0
- package/esm/components/page-layout/index.spec.js.map +1 -1
- package/esm/components/rating.d.ts.map +1 -1
- package/esm/components/rating.js +28 -21
- package/esm/components/rating.js.map +1 -1
- package/esm/components/rating.spec.js +151 -4
- package/esm/components/rating.spec.js.map +1 -1
- package/esm/components/suggest/index.d.ts.map +1 -1
- package/esm/components/suggest/index.js +14 -1
- package/esm/components/suggest/index.js.map +1 -1
- package/esm/components/suggest/index.spec.js +98 -43
- package/esm/components/suggest/index.spec.js.map +1 -1
- package/esm/components/tabs.d.ts.map +1 -1
- package/esm/components/tabs.js +4 -0
- package/esm/components/tabs.js.map +1 -1
- package/esm/components/tree/tree-item.d.ts.map +1 -1
- package/esm/components/tree/tree-item.js +18 -5
- package/esm/components/tree/tree-item.js.map +1 -1
- package/esm/components/tree/tree.d.ts +7 -0
- package/esm/components/tree/tree.d.ts.map +1 -1
- package/esm/components/tree/tree.js +12 -3
- package/esm/components/tree/tree.js.map +1 -1
- package/esm/components/tree/tree.spec.js +64 -2
- package/esm/components/tree/tree.spec.js.map +1 -1
- package/esm/services/collection-service.d.ts +9 -0
- package/esm/services/collection-service.d.ts.map +1 -1
- package/esm/services/collection-service.js +33 -11
- package/esm/services/collection-service.js.map +1 -1
- package/esm/services/collection-service.spec.js +33 -24
- package/esm/services/collection-service.spec.js.map +1 -1
- package/esm/services/css-variable-theme.d.ts +7 -0
- package/esm/services/css-variable-theme.d.ts.map +1 -1
- package/esm/services/css-variable-theme.js +23 -0
- package/esm/services/css-variable-theme.js.map +1 -1
- package/esm/services/css-variable-theme.spec.js +1 -0
- package/esm/services/css-variable-theme.spec.js.map +1 -1
- package/esm/services/list-service.d.ts +9 -0
- package/esm/services/list-service.d.ts.map +1 -1
- package/esm/services/list-service.js +13 -13
- package/esm/services/list-service.js.map +1 -1
- package/esm/services/list-service.spec.js +13 -33
- package/esm/services/list-service.spec.js.map +1 -1
- package/esm/services/theme-provider-service.d.ts +3 -0
- package/esm/services/theme-provider-service.d.ts.map +1 -1
- package/esm/services/theme-provider-service.js.map +1 -1
- package/esm/services/tree-service.d.ts.map +1 -1
- package/esm/services/tree-service.js +5 -9
- package/esm/services/tree-service.js.map +1 -1
- package/esm/services/tree-service.spec.js +12 -9
- package/esm/services/tree-service.spec.js.map +1 -1
- package/esm/themes/architect-theme.d.ts +1 -0
- package/esm/themes/architect-theme.d.ts.map +1 -1
- package/esm/themes/architect-theme.js +1 -0
- package/esm/themes/architect-theme.js.map +1 -1
- package/esm/themes/auditore-theme.d.ts +1 -0
- package/esm/themes/auditore-theme.d.ts.map +1 -1
- package/esm/themes/auditore-theme.js +1 -0
- package/esm/themes/auditore-theme.js.map +1 -1
- package/esm/themes/black-mesa-theme.d.ts +1 -0
- package/esm/themes/black-mesa-theme.d.ts.map +1 -1
- package/esm/themes/black-mesa-theme.js +1 -0
- package/esm/themes/black-mesa-theme.js.map +1 -1
- package/esm/themes/chieftain-theme.d.ts +1 -0
- package/esm/themes/chieftain-theme.d.ts.map +1 -1
- package/esm/themes/chieftain-theme.js +1 -0
- package/esm/themes/chieftain-theme.js.map +1 -1
- package/esm/themes/default-dark-theme.d.ts +1 -0
- package/esm/themes/default-dark-theme.d.ts.map +1 -1
- package/esm/themes/default-dark-theme.js +1 -0
- package/esm/themes/default-dark-theme.js.map +1 -1
- package/esm/themes/default-light-theme.d.ts +1 -0
- package/esm/themes/default-light-theme.d.ts.map +1 -1
- package/esm/themes/default-light-theme.js +1 -0
- package/esm/themes/default-light-theme.js.map +1 -1
- package/esm/themes/dragonborn-theme.d.ts +1 -0
- package/esm/themes/dragonborn-theme.d.ts.map +1 -1
- package/esm/themes/dragonborn-theme.js +1 -0
- package/esm/themes/dragonborn-theme.js.map +1 -1
- package/esm/themes/hawkins-theme.d.ts +1 -0
- package/esm/themes/hawkins-theme.d.ts.map +1 -1
- package/esm/themes/hawkins-theme.js +1 -0
- package/esm/themes/hawkins-theme.js.map +1 -1
- package/esm/themes/jedi-theme.d.ts +1 -0
- package/esm/themes/jedi-theme.d.ts.map +1 -1
- package/esm/themes/jedi-theme.js +1 -0
- package/esm/themes/jedi-theme.js.map +1 -1
- package/esm/themes/neon-runner-theme.d.ts +1 -0
- package/esm/themes/neon-runner-theme.d.ts.map +1 -1
- package/esm/themes/neon-runner-theme.js +1 -0
- package/esm/themes/neon-runner-theme.js.map +1 -1
- package/esm/themes/paladin-theme.d.ts +1 -0
- package/esm/themes/paladin-theme.d.ts.map +1 -1
- package/esm/themes/paladin-theme.js +1 -0
- package/esm/themes/paladin-theme.js.map +1 -1
- package/esm/themes/plumber-theme.d.ts +1 -0
- package/esm/themes/plumber-theme.d.ts.map +1 -1
- package/esm/themes/plumber-theme.js +1 -0
- package/esm/themes/plumber-theme.js.map +1 -1
- package/esm/themes/replicant-theme.d.ts +1 -0
- package/esm/themes/replicant-theme.d.ts.map +1 -1
- package/esm/themes/replicant-theme.js +1 -0
- package/esm/themes/replicant-theme.js.map +1 -1
- package/esm/themes/sandworm-theme.d.ts +1 -0
- package/esm/themes/sandworm-theme.d.ts.map +1 -1
- package/esm/themes/sandworm-theme.js +1 -0
- package/esm/themes/sandworm-theme.js.map +1 -1
- package/esm/themes/shadow-broker-theme.d.ts +1 -0
- package/esm/themes/shadow-broker-theme.d.ts.map +1 -1
- package/esm/themes/shadow-broker-theme.js +1 -0
- package/esm/themes/shadow-broker-theme.js.map +1 -1
- package/esm/themes/sith-theme.d.ts +1 -0
- package/esm/themes/sith-theme.d.ts.map +1 -1
- package/esm/themes/sith-theme.js +1 -0
- package/esm/themes/sith-theme.js.map +1 -1
- package/esm/themes/vault-dweller-theme.d.ts +1 -0
- package/esm/themes/vault-dweller-theme.d.ts.map +1 -1
- package/esm/themes/vault-dweller-theme.js +1 -0
- package/esm/themes/vault-dweller-theme.js.map +1 -1
- package/esm/themes/wild-hunt-theme.d.ts +1 -0
- package/esm/themes/wild-hunt-theme.d.ts.map +1 -1
- package/esm/themes/wild-hunt-theme.js +1 -0
- package/esm/themes/wild-hunt-theme.js.map +1 -1
- package/esm/themes/xenomorph-theme.d.ts +1 -0
- package/esm/themes/xenomorph-theme.d.ts.map +1 -1
- package/esm/themes/xenomorph-theme.js +1 -0
- package/esm/themes/xenomorph-theme.js.map +1 -1
- package/package.json +3 -3
- package/src/components/accordion/accordion-item.tsx +9 -14
- package/src/components/accordion/accordion.spec.tsx +134 -79
- package/src/components/accordion/accordion.tsx +13 -1
- package/src/components/carousel.tsx +1 -1
- package/src/components/chip.spec.tsx +64 -0
- package/src/components/chip.tsx +4 -1
- package/src/components/command-palette/index.spec.tsx +95 -33
- package/src/components/command-palette/index.tsx +15 -3
- package/src/components/data-grid/data-grid-row.tsx +20 -2
- package/src/components/data-grid/data-grid.spec.tsx +185 -57
- package/src/components/data-grid/data-grid.tsx +38 -13
- package/src/components/data-grid/selection-cell.tsx +1 -0
- package/src/components/dialog.spec.tsx +77 -2
- package/src/components/dialog.tsx +14 -1
- package/src/components/dropdown.spec.tsx +9 -0
- package/src/components/dropdown.tsx +1 -0
- package/src/components/image.spec.tsx +82 -0
- package/src/components/image.tsx +16 -7
- package/src/components/inputs/checkbox.tsx +1 -0
- package/src/components/inputs/radio.tsx +1 -0
- package/src/components/inputs/slider.tsx +1 -0
- package/src/components/inputs/switch.tsx +1 -0
- package/src/components/list/list-item.tsx +22 -4
- package/src/components/list/list.spec.tsx +165 -32
- package/src/components/list/list.tsx +37 -10
- package/src/components/markdown/markdown-display.spec.tsx +132 -0
- package/src/components/markdown/markdown-display.tsx +12 -1
- package/src/components/markdown/markdown-editor.spec.tsx +123 -0
- package/src/components/menu/menu.tsx +1 -1
- package/src/components/modal.spec.tsx +124 -1
- package/src/components/modal.tsx +41 -3
- package/src/components/page-layout/index.spec.tsx +20 -0
- package/src/components/page-layout/index.tsx +1 -1
- package/src/components/rating.spec.tsx +199 -4
- package/src/components/rating.tsx +28 -22
- package/src/components/suggest/index.spec.tsx +147 -43
- package/src/components/suggest/index.tsx +15 -2
- package/src/components/tabs.tsx +4 -0
- package/src/components/tree/tree-item.tsx +19 -4
- package/src/components/tree/tree.spec.tsx +101 -2
- package/src/components/tree/tree.tsx +21 -3
- package/src/services/collection-service.spec.ts +33 -24
- package/src/services/collection-service.ts +35 -13
- package/src/services/css-variable-theme.spec.ts +1 -0
- package/src/services/css-variable-theme.ts +25 -0
- package/src/services/list-service.spec.ts +13 -42
- package/src/services/list-service.ts +15 -13
- package/src/services/theme-provider-service.ts +2 -0
- package/src/services/tree-service.spec.ts +12 -9
- package/src/services/tree-service.ts +5 -8
- package/src/themes/architect-theme.ts +1 -0
- package/src/themes/auditore-theme.ts +1 -0
- package/src/themes/black-mesa-theme.ts +1 -0
- package/src/themes/chieftain-theme.ts +1 -0
- package/src/themes/default-dark-theme.ts +1 -0
- package/src/themes/default-light-theme.ts +1 -0
- package/src/themes/dragonborn-theme.ts +1 -0
- package/src/themes/hawkins-theme.ts +1 -0
- package/src/themes/jedi-theme.ts +1 -0
- package/src/themes/neon-runner-theme.ts +1 -0
- package/src/themes/paladin-theme.ts +1 -0
- package/src/themes/plumber-theme.ts +1 -0
- package/src/themes/replicant-theme.ts +1 -0
- package/src/themes/sandworm-theme.ts +1 -0
- package/src/themes/shadow-broker-theme.ts +1 -0
- package/src/themes/sith-theme.ts +1 -0
- package/src/themes/vault-dweller-theme.ts +1 -0
- package/src/themes/wild-hunt-theme.ts +1 -0
- package/src/themes/xenomorph-theme.ts +1 -0
|
@@ -240,4 +240,136 @@ describe('MarkdownDisplay', () => {
|
|
|
240
240
|
expect(root?.children.length).toBe(0)
|
|
241
241
|
})
|
|
242
242
|
})
|
|
243
|
+
|
|
244
|
+
describe('keyboard navigation', () => {
|
|
245
|
+
it('should make links focusable', async () => {
|
|
246
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
247
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
248
|
+
|
|
249
|
+
initializeShadeRoot({
|
|
250
|
+
injector,
|
|
251
|
+
rootElement,
|
|
252
|
+
jsxElement: <MarkdownDisplay content="[Link A](https://a.com) and [Link B](https://b.com)" />,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
await flushUpdates()
|
|
256
|
+
|
|
257
|
+
const links = document.querySelectorAll<HTMLAnchorElement>('shade-markdown-display .md-link')
|
|
258
|
+
expect(links.length).toBe(2)
|
|
259
|
+
|
|
260
|
+
links[0].focus()
|
|
261
|
+
expect(document.activeElement).toBe(links[0])
|
|
262
|
+
|
|
263
|
+
links[1].focus()
|
|
264
|
+
expect(document.activeElement).toBe(links[1])
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('should make code blocks focusable via tabIndex', async () => {
|
|
269
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
270
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
271
|
+
|
|
272
|
+
initializeShadeRoot({
|
|
273
|
+
injector,
|
|
274
|
+
rootElement,
|
|
275
|
+
jsxElement: <MarkdownDisplay content={'```js\nconsole.log("hi")\n```'} />,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
await flushUpdates()
|
|
279
|
+
|
|
280
|
+
const codeBlock = document.querySelector('shade-markdown-display .md-code-block') as HTMLPreElement
|
|
281
|
+
expect(codeBlock).not.toBeNull()
|
|
282
|
+
expect(codeBlock.tabIndex).toBe(0)
|
|
283
|
+
|
|
284
|
+
codeBlock.focus()
|
|
285
|
+
expect(document.activeElement).toBe(codeBlock)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should make checkbox inputs focusable when not disabled', async () => {
|
|
290
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
291
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
292
|
+
|
|
293
|
+
initializeShadeRoot({
|
|
294
|
+
injector,
|
|
295
|
+
rootElement,
|
|
296
|
+
jsxElement: <MarkdownDisplay content={'- [ ] Task A\n- [ ] Task B'} readOnly={false} onChange={() => {}} />,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
await flushUpdates()
|
|
300
|
+
|
|
301
|
+
const checkboxes = document.querySelectorAll<HTMLInputElement>(
|
|
302
|
+
'shade-markdown-display shade-checkbox input[type="checkbox"]',
|
|
303
|
+
)
|
|
304
|
+
expect(checkboxes.length).toBe(2)
|
|
305
|
+
|
|
306
|
+
checkboxes[0].focus()
|
|
307
|
+
expect(document.activeElement).toBe(checkboxes[0])
|
|
308
|
+
expect(checkboxes[0].disabled).toBe(false)
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('should toggle checkbox via keyboard activation', async () => {
|
|
313
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
314
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
315
|
+
const onChange = vi.fn()
|
|
316
|
+
|
|
317
|
+
initializeShadeRoot({
|
|
318
|
+
injector,
|
|
319
|
+
rootElement,
|
|
320
|
+
jsxElement: <MarkdownDisplay content="- [ ] My Task" readOnly={false} onChange={onChange} />,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
await flushUpdates()
|
|
324
|
+
|
|
325
|
+
const input = document.querySelector(
|
|
326
|
+
'shade-markdown-display shade-checkbox input[type="checkbox"]',
|
|
327
|
+
) as HTMLInputElement
|
|
328
|
+
expect(input).not.toBeNull()
|
|
329
|
+
|
|
330
|
+
input.focus()
|
|
331
|
+
expect(document.activeElement).toBe(input)
|
|
332
|
+
|
|
333
|
+
input.click()
|
|
334
|
+
await flushUpdates()
|
|
335
|
+
|
|
336
|
+
expect(onChange).toHaveBeenCalledOnce()
|
|
337
|
+
expect(onChange).toHaveBeenCalledWith('- [x] My Task')
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('should have correct focus order for mixed interactive elements', async () => {
|
|
342
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
343
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
344
|
+
|
|
345
|
+
const content = [
|
|
346
|
+
'[First link](https://first.com)',
|
|
347
|
+
'',
|
|
348
|
+
'```js',
|
|
349
|
+
'code()',
|
|
350
|
+
'```',
|
|
351
|
+
'',
|
|
352
|
+
'[Second link](https://second.com)',
|
|
353
|
+
].join('\n')
|
|
354
|
+
|
|
355
|
+
initializeShadeRoot({
|
|
356
|
+
injector,
|
|
357
|
+
rootElement,
|
|
358
|
+
jsxElement: <MarkdownDisplay content={content} />,
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
await flushUpdates()
|
|
362
|
+
|
|
363
|
+
const focusableElements = document.querySelectorAll(
|
|
364
|
+
'shade-markdown-display a[href], shade-markdown-display [tabindex="0"]',
|
|
365
|
+
)
|
|
366
|
+
expect(focusableElements.length).toBe(3)
|
|
367
|
+
|
|
368
|
+
const [firstLink, codeBlock, secondLink] = focusableElements
|
|
369
|
+
expect(firstLink.tagName).toBe('A')
|
|
370
|
+
expect(codeBlock.tagName).toBe('PRE')
|
|
371
|
+
expect(secondLink.tagName).toBe('A')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
})
|
|
243
375
|
})
|
|
@@ -60,7 +60,7 @@ const renderBlock = (
|
|
|
60
60
|
return <Typography variant="body1">{renderInline(node.children)}</Typography>
|
|
61
61
|
case 'codeBlock':
|
|
62
62
|
return (
|
|
63
|
-
<pre className="md-code-block" data-language={node.language || undefined}>
|
|
63
|
+
<pre className="md-code-block" data-language={node.language || undefined} tabIndex={0}>
|
|
64
64
|
<code>{node.content}</code>
|
|
65
65
|
</pre>
|
|
66
66
|
)
|
|
@@ -136,6 +136,11 @@ export const MarkdownDisplay = Shade<MarkdownDisplayProps>({
|
|
|
136
136
|
whiteSpace: 'pre',
|
|
137
137
|
},
|
|
138
138
|
|
|
139
|
+
'& .md-code-block:focus-visible': {
|
|
140
|
+
outline: cssVariableTheme.action.focusOutline,
|
|
141
|
+
outlineOffset: '-2px',
|
|
142
|
+
},
|
|
143
|
+
|
|
139
144
|
'& .md-blockquote': {
|
|
140
145
|
borderLeft: `4px solid ${cssVariableTheme.palette.primary.main}`,
|
|
141
146
|
margin: `${cssVariableTheme.spacing.sm} 0`,
|
|
@@ -150,6 +155,12 @@ export const MarkdownDisplay = Shade<MarkdownDisplayProps>({
|
|
|
150
155
|
'& .md-link:hover': {
|
|
151
156
|
textDecoration: 'underline',
|
|
152
157
|
},
|
|
158
|
+
'& .md-link:focus-visible': {
|
|
159
|
+
textDecoration: 'underline',
|
|
160
|
+
outline: cssVariableTheme.action.focusOutline,
|
|
161
|
+
outlineOffset: '2px',
|
|
162
|
+
borderRadius: cssVariableTheme.shape.borderRadius.xs,
|
|
163
|
+
},
|
|
153
164
|
|
|
154
165
|
'& .md-image': {
|
|
155
166
|
maxWidth: '100%',
|
|
@@ -400,4 +400,127 @@ describe('MarkdownEditor', () => {
|
|
|
400
400
|
})
|
|
401
401
|
})
|
|
402
402
|
})
|
|
403
|
+
|
|
404
|
+
describe('keyboard navigation', () => {
|
|
405
|
+
it('should have a focusable textarea in side-by-side layout', async () => {
|
|
406
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
407
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
408
|
+
|
|
409
|
+
initializeShadeRoot({
|
|
410
|
+
injector,
|
|
411
|
+
rootElement,
|
|
412
|
+
jsxElement: <MarkdownEditor value="# Hello" />,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
await flushUpdates()
|
|
416
|
+
|
|
417
|
+
const textarea = document.querySelector('shade-markdown-editor textarea') as HTMLTextAreaElement
|
|
418
|
+
expect(textarea).not.toBeNull()
|
|
419
|
+
|
|
420
|
+
textarea.focus()
|
|
421
|
+
expect(document.activeElement).toBe(textarea)
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('should have focusable tab buttons in tabs layout', async () => {
|
|
426
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
427
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
428
|
+
|
|
429
|
+
initializeShadeRoot({
|
|
430
|
+
injector,
|
|
431
|
+
rootElement,
|
|
432
|
+
jsxElement: <MarkdownEditor value="# Hello" layout="tabs" />,
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
await flushUpdates()
|
|
436
|
+
|
|
437
|
+
const tabButtons = document.querySelectorAll<HTMLButtonElement>('shade-markdown-editor .shade-tab-btn')
|
|
438
|
+
expect(tabButtons.length).toBe(2)
|
|
439
|
+
|
|
440
|
+
tabButtons[0].focus()
|
|
441
|
+
expect(document.activeElement).toBe(tabButtons[0])
|
|
442
|
+
|
|
443
|
+
tabButtons[1].focus()
|
|
444
|
+
expect(document.activeElement).toBe(tabButtons[1])
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('should use tabIndex to indicate active tab in controlled tabs layout', async () => {
|
|
449
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
450
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
451
|
+
|
|
452
|
+
initializeShadeRoot({
|
|
453
|
+
injector,
|
|
454
|
+
rootElement,
|
|
455
|
+
jsxElement: <MarkdownEditor value="# Hello" layout="tabs" />,
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
await flushUpdates()
|
|
459
|
+
|
|
460
|
+
const tabButtons = document.querySelectorAll<HTMLButtonElement>('shade-markdown-editor .shade-tab-btn')
|
|
461
|
+
|
|
462
|
+
const activeTab = Array.from(tabButtons).find((btn) => btn.classList.contains('active'))
|
|
463
|
+
const inactiveTab = Array.from(tabButtons).find((btn) => !btn.classList.contains('active'))
|
|
464
|
+
|
|
465
|
+
expect(activeTab).not.toBeUndefined()
|
|
466
|
+
expect(inactiveTab).not.toBeUndefined()
|
|
467
|
+
expect(activeTab?.tabIndex).toBe(0)
|
|
468
|
+
expect(inactiveTab?.tabIndex).toBe(-1)
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('should switch tabs when tab button is clicked via keyboard activation', async () => {
|
|
473
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
474
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
475
|
+
|
|
476
|
+
initializeShadeRoot({
|
|
477
|
+
injector,
|
|
478
|
+
rootElement,
|
|
479
|
+
jsxElement: <MarkdownEditor value="# Hello" layout="tabs" />,
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
await flushUpdates()
|
|
483
|
+
|
|
484
|
+
const tabButtons = document.querySelectorAll<HTMLButtonElement>('shade-markdown-editor .shade-tab-btn')
|
|
485
|
+
|
|
486
|
+
const previewButton = Array.from(tabButtons).find((btn) => btn.textContent?.includes('Preview'))
|
|
487
|
+
expect(previewButton).not.toBeUndefined()
|
|
488
|
+
|
|
489
|
+
previewButton!.click()
|
|
490
|
+
await flushUpdates()
|
|
491
|
+
|
|
492
|
+
const display = document.querySelector('shade-markdown-editor shade-markdown-display')
|
|
493
|
+
expect(display).not.toBeNull()
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('should have focusable interactive elements in preview pane', async () => {
|
|
498
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
499
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
500
|
+
|
|
501
|
+
initializeShadeRoot({
|
|
502
|
+
injector,
|
|
503
|
+
rootElement,
|
|
504
|
+
jsxElement: (
|
|
505
|
+
<MarkdownEditor
|
|
506
|
+
value={'- [ ] Task A\n- [x] Task B\n\n[A link](https://example.com)'}
|
|
507
|
+
onValueChange={() => {}}
|
|
508
|
+
/>
|
|
509
|
+
),
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
await flushUpdates()
|
|
513
|
+
|
|
514
|
+
const checkboxes = document.querySelectorAll(
|
|
515
|
+
'shade-markdown-editor shade-markdown-display shade-checkbox input[type="checkbox"]',
|
|
516
|
+
)
|
|
517
|
+
expect(checkboxes.length).toBe(2)
|
|
518
|
+
|
|
519
|
+
const link = document.querySelector('shade-markdown-editor shade-markdown-display .md-link')
|
|
520
|
+
expect(link).not.toBeNull()
|
|
521
|
+
;(checkboxes[0] as HTMLElement).focus()
|
|
522
|
+
expect(document.activeElement).toBe(checkboxes[0])
|
|
523
|
+
})
|
|
524
|
+
})
|
|
525
|
+
})
|
|
403
526
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Injector } from '@furystack/inject'
|
|
2
|
-
import { initializeShadeRoot, createComponent, Shade, flushUpdates } from '@furystack/shades'
|
|
2
|
+
import { initializeShadeRoot, createComponent, Shade, flushUpdates, SpatialNavigationService } from '@furystack/shades'
|
|
3
3
|
import { usingAsync } from '@furystack/utils'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import { Modal } from './modal.js'
|
|
@@ -308,4 +308,127 @@ describe('Modal', () => {
|
|
|
308
308
|
})
|
|
309
309
|
})
|
|
310
310
|
})
|
|
311
|
+
|
|
312
|
+
describe('spatial navigation', () => {
|
|
313
|
+
it('should render with data-nav-section attribute when visible', async () => {
|
|
314
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
315
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
316
|
+
|
|
317
|
+
initializeShadeRoot({
|
|
318
|
+
injector,
|
|
319
|
+
rootElement,
|
|
320
|
+
jsxElement: (
|
|
321
|
+
<Modal isVisible={true}>
|
|
322
|
+
<div>Content</div>
|
|
323
|
+
</Modal>
|
|
324
|
+
),
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
await flushUpdates()
|
|
328
|
+
const backdrop = document.querySelector('.shade-backdrop')
|
|
329
|
+
expect(backdrop?.getAttribute('data-nav-section')).toBeTruthy()
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('should render with custom navSection name', async () => {
|
|
334
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
335
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
336
|
+
|
|
337
|
+
initializeShadeRoot({
|
|
338
|
+
injector,
|
|
339
|
+
rootElement,
|
|
340
|
+
jsxElement: (
|
|
341
|
+
<Modal isVisible={true} navSection="my-modal">
|
|
342
|
+
<div>Content</div>
|
|
343
|
+
</Modal>
|
|
344
|
+
),
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
await flushUpdates()
|
|
348
|
+
const backdrop = document.querySelector('.shade-backdrop')
|
|
349
|
+
expect(backdrop?.getAttribute('data-nav-section')).toBe('my-modal')
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('should push focus trap when trapFocus is true and service is active', async () => {
|
|
354
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
355
|
+
const spatialNav = injector.getInstance(SpatialNavigationService)
|
|
356
|
+
const pushSpy = vi.spyOn(spatialNav, 'pushFocusTrap')
|
|
357
|
+
|
|
358
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
359
|
+
|
|
360
|
+
initializeShadeRoot({
|
|
361
|
+
injector,
|
|
362
|
+
rootElement,
|
|
363
|
+
jsxElement: (
|
|
364
|
+
<Modal isVisible={true} trapFocus={true} navSection="trapped-modal">
|
|
365
|
+
<div>Content</div>
|
|
366
|
+
</Modal>
|
|
367
|
+
),
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
await flushUpdates()
|
|
371
|
+
|
|
372
|
+
expect(pushSpy).toHaveBeenCalledWith('trapped-modal')
|
|
373
|
+
expect(spatialNav.activeSection.getValue()).toBe('trapped-modal')
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('should not push focus trap when trapFocus is false', async () => {
|
|
378
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
379
|
+
const spatialNav = injector.getInstance(SpatialNavigationService)
|
|
380
|
+
const pushSpy = vi.spyOn(spatialNav, 'pushFocusTrap')
|
|
381
|
+
|
|
382
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
383
|
+
|
|
384
|
+
initializeShadeRoot({
|
|
385
|
+
injector,
|
|
386
|
+
rootElement,
|
|
387
|
+
jsxElement: (
|
|
388
|
+
<Modal isVisible={true} trapFocus={false}>
|
|
389
|
+
<div>Content</div>
|
|
390
|
+
</Modal>
|
|
391
|
+
),
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
await flushUpdates()
|
|
395
|
+
|
|
396
|
+
expect(pushSpy).not.toHaveBeenCalled()
|
|
397
|
+
spatialNav.activeSection.setValue('other-section')
|
|
398
|
+
expect(spatialNav.activeSection.getValue()).toBe('other-section')
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('should pop focus trap when visibility changes from true to false', async () => {
|
|
403
|
+
let setVisible!: (v: boolean) => void
|
|
404
|
+
|
|
405
|
+
const Wrapper = Shade({
|
|
406
|
+
customElementName: 'modal-trap-visibility-test',
|
|
407
|
+
render: ({ useState }) => {
|
|
408
|
+
const [visible, setter] = useState('visible', true)
|
|
409
|
+
setVisible = setter
|
|
410
|
+
return (
|
|
411
|
+
<Modal isVisible={visible} trapFocus={true} navSection="trap-test">
|
|
412
|
+
<div>Content</div>
|
|
413
|
+
</Modal>
|
|
414
|
+
)
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
419
|
+
const spatialNav = injector.getInstance(SpatialNavigationService)
|
|
420
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
421
|
+
|
|
422
|
+
initializeShadeRoot({ injector, rootElement, jsxElement: <Wrapper /> })
|
|
423
|
+
await flushUpdates()
|
|
424
|
+
|
|
425
|
+
expect(spatialNav.activeSection.getValue()).toBe('trap-test')
|
|
426
|
+
|
|
427
|
+
setVisible(false)
|
|
428
|
+
await flushUpdates()
|
|
429
|
+
|
|
430
|
+
expect(spatialNav.activeSection.getValue()).not.toBe('trap-test')
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
})
|
|
311
434
|
})
|
package/src/components/modal.tsx
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
import { Shade, createComponent } from '@furystack/shades'
|
|
1
|
+
import { Shade, createComponent, SpatialNavigationService } from '@furystack/shades'
|
|
2
2
|
import { cssVariableTheme } from '../services/css-variable-theme.js'
|
|
3
3
|
|
|
4
|
+
let nextModalId = 0
|
|
5
|
+
|
|
4
6
|
export type ModalProps = {
|
|
5
7
|
backdropStyle?: Partial<CSSStyleDeclaration>
|
|
6
8
|
isVisible: boolean
|
|
7
9
|
onClose?: () => void
|
|
8
10
|
showAnimation?: (el: Element | null) => Promise<unknown>
|
|
9
11
|
hideAnimation?: (el: Element | null) => Promise<unknown>
|
|
12
|
+
/**
|
|
13
|
+
* When true, traps spatial navigation within the modal's bounds.
|
|
14
|
+
* If SpatialNavigationService is not yet instantiated, it will be created with defaults.
|
|
15
|
+
*/
|
|
16
|
+
trapFocus?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Section name for spatial navigation scoping.
|
|
19
|
+
* @default 'modal'
|
|
20
|
+
*/
|
|
21
|
+
navSection?: string
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
export const Modal = Shade<ModalProps>({
|
|
@@ -22,9 +34,34 @@ export const Modal = Shade<ModalProps>({
|
|
|
22
34
|
left: '0',
|
|
23
35
|
},
|
|
24
36
|
},
|
|
25
|
-
render: ({ props, children, useRef }) => {
|
|
26
|
-
const { isVisible } = props
|
|
37
|
+
render: ({ props, children, injector, useRef, useDisposable, useState }) => {
|
|
38
|
+
const { isVisible, trapFocus, navSection } = props
|
|
27
39
|
const backdropRef = useRef<HTMLDivElement>('backdrop')
|
|
40
|
+
const [generatedSectionId] = useState('generatedSectionId', String(nextModalId++))
|
|
41
|
+
const sectionName = navSection ?? `modal-${generatedSectionId}`
|
|
42
|
+
|
|
43
|
+
useDisposable(
|
|
44
|
+
'spatial-nav-trap',
|
|
45
|
+
() => {
|
|
46
|
+
if (!isVisible || !trapFocus) return { [Symbol.dispose]: () => {} }
|
|
47
|
+
|
|
48
|
+
const spatialNav = injector.getInstance(SpatialNavigationService)
|
|
49
|
+
|
|
50
|
+
const previousSection = spatialNav.activeSection.getValue()
|
|
51
|
+
spatialNav.pushFocusTrap(sectionName)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
[Symbol.dispose]: () => {
|
|
55
|
+
try {
|
|
56
|
+
spatialNav.popFocusTrap(sectionName, previousSection)
|
|
57
|
+
} catch {
|
|
58
|
+
// Service may already be disposed during injector teardown
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[isVisible, trapFocus],
|
|
64
|
+
)
|
|
28
65
|
|
|
29
66
|
if (isVisible) {
|
|
30
67
|
queueMicrotask(() => {
|
|
@@ -36,6 +73,7 @@ export const Modal = Shade<ModalProps>({
|
|
|
36
73
|
<div
|
|
37
74
|
ref={backdropRef}
|
|
38
75
|
className="shade-backdrop"
|
|
76
|
+
data-nav-section={sectionName}
|
|
39
77
|
onclick={async () => {
|
|
40
78
|
await props.hideAnimation?.(backdropRef.current)
|
|
41
79
|
props.onClose?.()
|
|
@@ -647,6 +647,26 @@ describe('PageLayout component', () => {
|
|
|
647
647
|
})
|
|
648
648
|
})
|
|
649
649
|
|
|
650
|
+
it('should set data-nav-section="content" on the content area for spatial navigation scoping', async () => {
|
|
651
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
652
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
653
|
+
|
|
654
|
+
initializeShadeRoot({
|
|
655
|
+
injector,
|
|
656
|
+
rootElement,
|
|
657
|
+
jsxElement: (
|
|
658
|
+
<PageLayout>
|
|
659
|
+
<div>Content</div>
|
|
660
|
+
</PageLayout>
|
|
661
|
+
),
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
await flushUpdates()
|
|
665
|
+
const contentArea = document.querySelector('.page-layout-content')
|
|
666
|
+
expect(contentArea?.getAttribute('data-nav-section')).toBe('content')
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
650
670
|
it('should set CSS variable for zero paddingTop when no AppBar is configured', async () => {
|
|
651
671
|
await usingAsync(new Injector(), async (injector) => {
|
|
652
672
|
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
@@ -407,7 +407,7 @@ export const PageLayout = Shade<PageLayoutProps>({
|
|
|
407
407
|
)}
|
|
408
408
|
|
|
409
409
|
{/* Main Content */}
|
|
410
|
-
<main className="page-layout-content" data-testid="page-layout-content">
|
|
410
|
+
<main className="page-layout-content" data-testid="page-layout-content" data-nav-section="content">
|
|
411
411
|
{children}
|
|
412
412
|
</main>
|
|
413
413
|
</div>
|