Skip to content

Commit 31e4aa9

Browse files
[ToggleGroup]: New component (#258)
* New component: ToggleGroup * Lint/fix
1 parent 5bad06d commit 31e4aa9

File tree

14 files changed

+517
-15
lines changed

14 files changed

+517
-15
lines changed

docs-app/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ app/templates/*.gts
33
# unconventional js
44
/blueprints/*/files/
55
/vendor/
6+
.eslintcache
67

78
# compiled output
89
/dist/

docs-app/app/routes/application.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Route from '@ember/routing/route';
2+
import { service } from '@ember/service';
3+
4+
import type { SetupService } from 'ember-primitives';
5+
6+
export default class Application extends Route {
7+
@service('ember-primitives/setup') declare primitives: SetupService;
8+
9+
beforeModel() {
10+
this.primitives.setup();
11+
}
12+
}

docs-app/public/docs/2-accessibility/intro.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,39 @@ import { Switch } from 'ember-primitives';
4141
-->
4242

4343
This only happens during development, and in production, the CSS that applies these warnings is not included.
44+
45+
## Keyboard Support
46+
47+
ember-primitives uses _The Platform_ where possible and implements W3C recommendations for patterns where _The Platform_ does not provide solutions. To help lift the burden of maintenance for keyboard support implementation, ember-primitives uses [tabster](https://tabster.io/) for adding that additional keyboard support.
48+
49+
This keyboard support is enabled by default but does require initialization. You can initialize keyboard support in your application router by calling the `setup()` method on the `ember-primitives/setup` service:
50+
```ts
51+
import Route from '@ember/routing/route';
52+
import { service } from '@ember/service';
53+
54+
import type { SetupService } from 'ember-primitives';
55+
56+
export default class Application extends Route {
57+
@service('ember-primitives/setup') declare primitives: SetupService;
58+
59+
beforeModel() {
60+
this.primitives.setup();
61+
}
62+
}
63+
```
64+
65+
This is customizable, in case your application already uses tabster -- you may pass options to the `setup()` method:
66+
```ts
67+
// To use your own tabster
68+
this.primitives.setup({ tabster: myTabsterCoreInstance });
69+
// To specify your own "tabster root"
70+
this.primitives.setup({ setTabsterRoot: false });
71+
```
72+
73+
The tabster root is an element which which tells tabster to pay attention for tabster-using features.
74+
It can be set this way:
75+
```html
76+
<div data-tabster='{ "root": {} }'></div>
77+
```
78+
79+
By default, this attribute-value pair is set on the `body` element.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# ToggleGroup
2+
3+
A group of two-state buttons that can be toggled on or off.
4+
5+
6+
<div class="featured-demo">
7+
8+
```gjs live preview no-shadow
9+
import { ToggleGroup } from 'ember-primitives';
10+
11+
<template>
12+
<div class="demo">
13+
<ToggleGroup class="toggle-group" as |t|>
14+
<t.Item @value="left" aria-label="Left align">
15+
<AlignLeft />
16+
</t.Item>
17+
<t.Item @value="center" aria-label="Center align">
18+
<AlignCenter />
19+
</t.Item>
20+
<t.Item @value="right" aria-label="Right align">
21+
<AlignRight />
22+
</t.Item>
23+
</ToggleGroup>
24+
</div>
25+
26+
<style>
27+
.demo { display: flex; justify-content: center; align-items: center;}
28+
.toggle-group {
29+
display: inline-flex;
30+
background-color: #fff;
31+
border-radius: 0.25rem;
32+
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));
33+
}
34+
35+
.toggle-group > button {
36+
background-color: white;
37+
color: #fff;
38+
height: 35px;
39+
width: 35px;
40+
display: flex;
41+
font-size: 15px;
42+
padding: 0.5rem;
43+
line-height: 1;
44+
align-items: center;
45+
justify-content: center;
46+
margin-left: 1px;
47+
border: 0;
48+
}
49+
.toggle-group > button:first-child {
50+
margin-left: 0;
51+
border-top-left-radius: 4px;
52+
border-bottom-left-radius: 4px;
53+
}
54+
.toggle-group > button:last-child {
55+
border-top-right-radius: 4px;
56+
border-bottom-right-radius: 4px;
57+
}
58+
.toggle-group > button:hover {
59+
background-color: #eee;
60+
}
61+
.toggle-group > button[aria-pressed='true'] {
62+
background-color: #ddf;
63+
color: black;
64+
}
65+
.toggle-group > button:focus {
66+
position: relative;
67+
box-shadow: 0 0 0 2px black;
68+
}
69+
</style>
70+
</template>
71+
72+
const AlignLeft = <template>
73+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M288 64c0 17.7-14.3 32-32 32H32C14.3 96 0 81.7 0 64S14.3 32 32 32H256c17.7 0 32 14.3 32 32zm0 256c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H256c17.7 0 32 14.3 32 32zM0 192c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 448c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg>
74+
</template>;
75+
const AlignCenter = <template>
76+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M352 64c0-17.7-14.3-32-32-32H128c-17.7 0-32 14.3-32 32s14.3 32 32 32H320c17.7 0 32-14.3 32-32zm96 128c0-17.7-14.3-32-32-32H32c-17.7 0-32 14.3-32 32s14.3 32 32 32H416c17.7 0 32-14.3 32-32zM0 448c0 17.7 14.3 32 32 32H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H32c-17.7 0-32 14.3-32 32zM352 320c0-17.7-14.3-32-32-32H128c-17.7 0-32 14.3-32 32s14.3 32 32 32H320c17.7 0 32-14.3 32-32z"/></svg>
77+
</template>;
78+
const AlignRight = <template>
79+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M448 64c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32zm0 256c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32zM0 192c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 448c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg>
80+
</template>;
81+
```
82+
83+
</div>
84+
85+
## Features
86+
87+
* Full keyboard navigation
88+
* Supports horizontal / vertical orientation
89+
* Support single and multiple pressed buttons
90+
* Can be controlled or uncontrolled
91+
92+
## Installation
93+
94+
```bash
95+
pnpm add ember-primitives
96+
```
97+
98+
## Anatomy
99+
100+
```js
101+
import { ToggleGroup } from 'ember-primitives';
102+
```
103+
104+
or for non-tree-shaking environments:
105+
```js
106+
import { ToggleGroup } from 'ember-primitives/components/toggle';
107+
```
108+
109+
110+
```gjs
111+
import { ToggleGroup } from 'ember-primitives';
112+
113+
<template>
114+
<ToggleGroup as |t|>
115+
<t.Item />
116+
</ToggleGroup>
117+
</template>
118+
```
119+
120+
## API Reference
121+
122+
```gjs live no-shadow
123+
import { ComponentSignature } from 'docs-app/docs-support';
124+
125+
<template>
126+
<ComponentSignature @module="components/toggle-group" @name="ToggleGroup" />
127+
</template>
128+
```
129+
130+
<hr>
131+
132+
### Item
133+
134+
```gjs live no-shadow
135+
import { ComponentSignature } from 'docs-app/docs-support';
136+
137+
<template>
138+
<ComponentSignature @module="components/toggle-group" @name="ItemSignature" />
139+
</template>
140+
```
141+
142+
## Accessibility
143+
144+
Uses [roving tabindex](https://www.w3.org/TR/wai-aria-practices-1.2/examples/radio/radio.html) to manage focus movement among items using the [tabster](https://tabster.io/) library.
145+
146+
As a caveat to using [tabster](https://tabster.io/), a "tabster-root" will need to be established separately if this component is used within a shadow-root, which escapes the parent-DOM's tabster instance.
147+
148+
For more information, see [The Accessibility guide](/2-accessibility/intro.md).
149+
150+
### Keyboard Interactions
151+
152+
| key | description |
153+
| :---: | :----------- |
154+
| <kbd>Tab</kbd> | Moves focus to the first item in the group |
155+
| <kbd>Space</kbd> | Toggles the item's state |
156+
| <kbd>Enter</kbd> | Toggles the item's state |
157+
| <kbd>ArrowDown</kbd> | Moves focus to the next item in the group |
158+
| <kbd>ArrowRight</kbd> | Moves focus to the next item in the group |
159+
| <kbd>ArrowDown</kbd> | Moves focus to the previous item in the group |
160+
| <kbd>ArrowLeft</kbd> | Moves focus to the previous item in the group |
161+
| <kbd>Home</kbd> | Moves focus to the first item in the group |
162+
| <kbd>End</kbd> | Moves focus to the last item in the group |

ember-primitives/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"ember-element-helper": "^0.8.4",
4444
"ember-velcro": "^2.1.3",
4545
"reactiveweb": "^1.2.0",
46+
"tabster": "^7.0.1",
4647
"tracked-toolbox": "^2.0.0"
4748
},
4849
"devDependencies": {
@@ -115,8 +116,10 @@
115116
"./components/scroller.js": "./dist/_app_/components/scroller.js",
116117
"./components/shadowed.js": "./dist/_app_/components/shadowed.js",
117118
"./components/switch.js": "./dist/_app_/components/switch.js",
119+
"./components/toggle-group.js": "./dist/_app_/components/toggle-group.js",
118120
"./components/toggle.js": "./dist/_app_/components/toggle.js",
119-
"./helpers/service.js": "./dist/_app_/helpers/service.js"
121+
"./helpers/service.js": "./dist/_app_/helpers/service.js",
122+
"./services/ember-primitives/setup.js": "./dist/_app_/services/ember-primitives/setup.js"
120123
}
121124
},
122125
"exports": {

ember-primitives/rollup.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default {
1515
output: addon.output(),
1616
plugins: [
1717
addon.publicEntrypoints(["**/*.js"]),
18-
addon.appReexports(["components/*.js", "helpers/**/*.js"]),
18+
addon.appReexports(["components/*.js", "helpers/**/*.js", "services/**/*.js"]),
1919
addon.dependencies(),
2020
babel({ extensions, babelHelpers: "inline" }),
2121
addon.hbs(),

ember-primitives/src/components/-private/utils.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
* If the user provides an onChange or similar function, use that,
33
* otherwise fallback to the uncontrolled toggle
44
*/
5-
export function toggleWithFallback(uncontrolledToggle: () => void, controlledToggle?: () => void) {
5+
export function toggleWithFallback(
6+
uncontrolledToggle: (...args: unknown[]) => void,
7+
controlledToggle?: (...args: unknown[]) => void,
8+
...args: unknown[]
9+
) {
610
if (controlledToggle) {
7-
return controlledToggle();
11+
return controlledToggle(...args);
812
}
913

10-
uncontrolledToggle();
14+
uncontrolledToggle(...args);
1115
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Component from '@glimmer/component';
2+
import { hash } from '@ember/helper';
3+
4+
import { Types } from 'tabster';
5+
// The consumer will need to provide types for tracked-toolbox.
6+
// Or.. better yet, we PR to trakcked-toolbox to provide them
7+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
8+
// @ts-ignore
9+
import { localCopy } from 'tracked-toolbox';
10+
11+
import { Toggle } from './toggle.gts';
12+
13+
import type { ComponentLike } from '@glint/template';
14+
15+
const TABSTER_CONFIG = JSON.stringify({
16+
mover: {
17+
direction: Types.MoverDirections.Both,
18+
cyclic: true,
19+
},
20+
});
21+
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
export interface ItemSignature<Value = any> {
24+
/**
25+
* The button element will have aria-pressed="true" on it when the button is in the pressed state.
26+
*/
27+
Element: HTMLButtonElement;
28+
Args: {
29+
/**
30+
* When used in a group of Toggles, this option will be helpful to
31+
* know which toggle was pressed if you're using the same @onChange
32+
* handler for multiple toggles.
33+
*/
34+
value?: Value;
35+
};
36+
Blocks: {
37+
default: [
38+
/**
39+
* the current pressed state of the toggle button
40+
*
41+
* Useful when using the toggle button as an uncontrolled component
42+
*/
43+
pressed: boolean,
44+
];
45+
};
46+
}
47+
48+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49+
export type Item<Value = any> = ComponentLike<ItemSignature<Value>>;
50+
51+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52+
export class ToggleGroup<Value = any> extends Component<{
53+
Element: HTMLDivElement;
54+
Args: {
55+
/**
56+
* Optionally set the initial toggle state
57+
*/
58+
value?: Value;
59+
/**
60+
* Callback for when the toggle-group's state is changed.
61+
*
62+
* Can be used to control the state of the component.
63+
*
64+
*
65+
* When none of the toggles are selected, undefined will be passed.
66+
*/
67+
onChange: (value: Value | undefined) => void;
68+
};
69+
Blocks: {
70+
default: [
71+
{
72+
Item: Item;
73+
},
74+
];
75+
};
76+
}> {
77+
@localCopy('args.value') activePressed?: Value;
78+
79+
handleToggle = (value: Value) => {
80+
if (this.activePressed === value) {
81+
this.activePressed = undefined;
82+
83+
return;
84+
}
85+
86+
this.activePressed = value;
87+
88+
this.args.onChange?.(this.activePressed);
89+
};
90+
91+
isPressed = (value: Value | undefined) => value === this.activePressed;
92+
93+
<template>
94+
<div data-tabster={{TABSTER_CONFIG}} ...attributes>
95+
{{yield (hash Item=(component Toggle onChange=this.handleToggle isPressed=this.isPressed))}}
96+
</div>
97+
</template>
98+
}

0 commit comments

Comments
 (0)