Skip to content

Commit 516db7a

Browse files
Merge pull request #646 from universal-ember/switch-component-needs-to-have-configurable-thumb-button
Switch needs configurable thumb
2 parents d6bc0ae + 25c71fd commit 516db7a

5 files changed

Lines changed: 243 additions & 98 deletions

File tree

docs-app/app/routes/application.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,18 @@ export default class Application extends Route {
145145
};
146146
},
147147
'reactiveweb/remote-data': () => import('reactiveweb/remote-data'),
148+
'reactiveweb/debounce': () => import('reactiveweb/debounce'),
149+
'reactiveweb/sync': () => import('reactiveweb/sync'),
150+
'reactiveweb/throttle': () => import('reactiveweb/throttle'),
151+
'reactiveweb/link': () => import('reactiveweb/link'),
152+
'reactiveweb/document-head': () => import('reactiveweb/document-head'),
153+
'reactiveweb/effect': () => import('reactiveweb/effect'),
154+
'reactiveweb/fps': () => import('reactiveweb/fps'),
155+
'reactiveweb/function': () => import('reactiveweb/function'),
156+
'reactiveweb/get-promise-state': () => import('reactiveweb/get-promise-state'),
157+
'reactiveweb/image': () => import('reactiveweb/image'),
158+
'reactiveweb/keep-latest': () => import('reactiveweb/keep-latest'),
159+
'reactiveweb/wait-until': () => import('reactiveweb/wait-until'),
148160
'ember-focus-trap/modifiers/focus-trap': () =>
149161
// @ts-expect-error - no types provided
150162
import('ember-focus-trap/modifiers/focus-trap'),

docs-app/public/docs/3-ui/switch.md

Lines changed: 142 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ The Switch component is a user interface element used for toggling between two s
1111
See [Bootstrap Switch](https://getbootstrap.com/docs/5.3/forms/checks-radios/#switches) docs.
1212

1313
```gjs live preview
14-
import { Switch, Shadowed } from 'ember-primitives';
14+
import { Switch } from 'ember-primitives/components/switch';
15+
import { Shadowed } from 'ember-primitives/components/shadowed';
1516
1617
<template>
1718
<Shadowed>
@@ -29,8 +30,7 @@ import { Switch, Shadowed } from 'ember-primitives';
2930
```
3031

3132
</details>
32-
33-
<details><summary><h3>Dark/Light Theme Switch</h3></summary>
33+
<details open><summary><h3>Dark/Light Theme Switch</h3></summary>
3434

3535
CSS inspired/taken from [this Codepen](https://codepen.io/Umer_Farooq/pen/eYJgKGN?editors=1100)
3636

@@ -47,93 +47,156 @@ const toggleTheme = (e) =>
4747
<s.Control {{on 'change' toggleTheme}} />
4848
<s.Label>
4949
<span class="sr-only">Toggle between light and dark mode</span>
50-
<Moon />
51-
<Sun />
52-
<span class="ball"></span>
50+
<span class="ball" data-state={{if s.isChecked "on" "off"}}>
51+
{{#if s.isChecked}}
52+
<Moon />
53+
{{else}}
54+
<Sun />
55+
{{/if}}
56+
</span>
5357
</s.Label>
5458
</Switch>
5559
5660
<style>
57-
@import url("https://fonts.googleapis.com/css2?family=Montserrat&display=swap");
58-
5961
* {box-sizing: border-box;}
6062
61-
div {
62-
padding: 1rem;
63-
font-family: "Montserrat", sans-serif;
64-
background-color: #eee;
65-
display: flex;
66-
justify-content: center;
67-
align-items: center;
68-
flex-direction: column;
69-
text-align: center;
70-
margin: 0;
71-
transition: background 0.2s linear;
72-
}
73-
74-
div.dark {background-color: #292c35;}
75-
div.dark label { background-color: #9b59b6; }
76-
77-
78-
input[type='checkbox'][role='switch'] {
79-
opacity: 0;
80-
position: absolute;
81-
}
82-
83-
.sr-only {
84-
width: 0px;
85-
max-width: 0px;
86-
height: 0px;
87-
max-height: 0px;
88-
overflow: hidden;
89-
margin-left: -0.5rem;
63+
@scope {
64+
div {
65+
padding: 1rem;
66+
background-color: #eee;
67+
display: flex;
68+
justify-content: center;
69+
align-items: center;
70+
flex-direction: column;
71+
text-align: center;
72+
margin: 0;
73+
transition: background 0.2s linear;
74+
width: 100%;
75+
}
76+
77+
div.dark {background-color: #292c35;}
78+
div.dark label { background-color: #9b59b6; }
79+
80+
81+
input[type='checkbox'][role='switch'] {
82+
touch-action: pan-y;
83+
opacity: 0;
84+
position: absolute;
85+
}
86+
87+
.sr-only {
88+
width: 0px;
89+
max-width: 0px;
90+
height: 0px;
91+
max-height: 0px;
92+
overflow: hidden;
93+
margin-left: -0.5rem;
94+
}
95+
96+
label {
97+
background-color: #aaaaff;
98+
border: 1px solid;
99+
width: 60px;
100+
height: 32px;
101+
border-radius: 50px;
102+
position: relative;
103+
padding: 5px;
104+
cursor: pointer;
105+
display: flex;
106+
justify-content: space-between;
107+
align-items: center;
108+
gap: 0.5rem;
109+
}
110+
111+
svg { fill: currentColor; position: absolute; top: 3px; left: 3px; }
112+
.moon { color: #f1c4ff; }
113+
.sun { color: #f39c12; }
114+
115+
label .ball {
116+
background-color: #111;
117+
width: 26px;
118+
height: 26px;
119+
position: absolute;
120+
left: 2px;
121+
top: 2px;
122+
border-radius: 50%;
123+
transition-property: transform filter;
124+
transition-duration: 0.2s;
125+
transition-timing-function: linear(0, 0.1, 0.25, 0.5, 0.68, 0.8, 0.88, 0.94, 0.98, 0.995, 1);;
126+
border: 2px solid #f1c40f;
127+
128+
&[data-state="on"] {
129+
border: 2px solid #f1c4ff;
130+
}
131+
}
132+
133+
label:hover .ball {
134+
filter: drop-shadow(0 0 3px #f1c40f);
135+
}
136+
label:active .ball {
137+
filter: drop-shadow(0 0 10px #f1c40f);
138+
}
139+
input[type='checkbox'][role='switch']:checked + label .ball {
140+
transform: translateX(28px);
141+
}
142+
input[type='checkbox'][role='switch']:checked:hover + label .ball {
143+
filter: drop-shadow(0 0 3px #f1c4ff);
144+
}
145+
input[type='checkbox'][role='switch']:checked:active + label .ball {
146+
filter: drop-shadow(0 0 10px #f1c4ff);
147+
}
90148
}
91-
92-
label {
93-
background-color: #111;
94-
width: 50px;
95-
height: 26px;
96-
border-radius: 50px;
97-
position: relative;
98-
padding: 5px;
99-
cursor: pointer;
100-
display: flex;
101-
justify-content: space-between;
102-
align-items: center;
103-
gap: 0.5rem;
104-
}
105-
106-
svg { fill: currentColor; }
107-
.fa-moon { color: #f1c40f; }
108-
.fa-sun { color: #f39c12; }
109-
110-
label .ball {
111-
background-color: #fff;
112-
width: 22px;
113-
height: 22px;
114-
position: absolute;
115-
left: 2px;
116-
top: 2px;
117-
border-radius: 50%;
118-
transition: transform 0.2s linear;
119-
}
120-
121-
input[type='checkbox'][role='switch']:checked + label .ball {
122-
transform: translateX(24px);
123-
}
124-
125149
</style>
126150
</Shadowed>
127151
</template>
128152
129153
// 🎵 It's raining, it's pouring, ... 🎵
130154
// https://www.youtube.com/watch?v=ll5ykbAumD4
131155
const Sun = <template>
132-
<svg class="fa-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">{{!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --}}<path d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"/></svg>
156+
<svg
157+
class="sun"
158+
xmlns="http://www.w3.org/2000/svg"
159+
width="16"
160+
height="16"
161+
viewBox="0 0 16 16"
162+
fill="none"
163+
stroke="currentColor"
164+
stroke-width="1.5"
165+
stroke-linecap="round"
166+
stroke-linejoin="round"
167+
aria-hidden="true"
168+
>
169+
<circle cx="8" cy="8" r="3.25" />
170+
<line x1="8" y1="1" x2="8" y2="3" />
171+
<line x1="8" y1="13" x2="8" y2="15" />
172+
<line x1="1" y1="8" x2="3" y2="8" />
173+
<line x1="13" y1="8" x2="15" y2="8" />
174+
<line x1="3.05" y1="3.05" x2="4.47" y2="4.47" />
175+
<line x1="11.53" y1="11.53" x2="12.95" y2="12.95" />
176+
<line x1="11.53" y1="4.47" x2="12.95" y2="3.05" />
177+
<line x1="3.05" y1="12.95" x2="4.47" y2="11.53" />
178+
</svg>
133179
</template>;
134180
135181
const Moon = <template>
136-
<svg class="fa-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">{{!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --}}<path d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"/></svg>
182+
<svg
183+
xmlns="http://www.w3.org/2000/svg"
184+
class="moon"
185+
width="16"
186+
height="16"
187+
viewBox="0 0 16 16"
188+
fill="none"
189+
stroke="currentColor"
190+
stroke-width="1.5"
191+
stroke-linecap="round"
192+
stroke-linejoin="round"
193+
aria-hidden="true"
194+
>
195+
<path
196+
transform="translate(-1 0)"
197+
d="M11.5 2a5.5 5.5 0 1 0 2 9.5 4.5 4.5 0 0 1 -2 -9.5z"
198+
/>
199+
</svg>
137200
</template>;
138201
```
139202

@@ -234,3 +297,9 @@ Adheres to the `switch` [role requirements](https://www.w3.org/WAI/ARIA/apg/patt
234297
| <kbd>Enter</kbd> | Toggles the component's state |
235298

236299
In addition, a label is required so that users know what the switch is for.
300+
301+
## References
302+
303+
- https://web.dev/articles/building/a-switch-component
304+
- https://getbootstrap.com/docs/5.3/forms/checks-radios/#switches
305+
- https://web.dev/articles/building/a-switch-component

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
* otherwise fallback to the uncontrolled toggle
44
*/
55
export function toggleWithFallback(
6-
uncontrolledToggle: (...args: unknown[]) => void,
7-
6+
uncontrolledToggle: undefined | ((...args: any[]) => void) | (() => void),
87
controlledToggle?: (...args: any[]) => void,
98
...args: unknown[]
109
) {
1110
if (controlledToggle) {
1211
return controlledToggle(...args);
1312
}
1413

15-
uncontrolledToggle(...args);
14+
uncontrolledToggle?.(...args);
1615
}

ember-primitives/src/components/switch.gts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,27 @@ export interface Signature {
2727
Blocks: {
2828
default?: [
2929
{
30+
/**
31+
* The current state of the Switch.
32+
*
33+
* ```gjs
34+
* import { Switch } from 'ember-primitives/components/switch';
35+
*
36+
* <template>
37+
* <Switch as |s|>
38+
* {{s.isChecked}}
39+
* </Switch>
40+
* </template>
41+
* ```
42+
*/
43+
isChecked: boolean;
3044
/**
3145
* The Switch Element.
3246
* It has a pre-wired `id` so that the relevant Label is
3347
* appropriately associated via the `for` property of the Label.
3448
*
3549
* ```gjs
36-
* import { Switch } from 'ember-primitives';
50+
* import { Switch } from 'ember-primitives/components/switch';
3751
*
3852
* <template>
3953
* <Switch as |s|>
@@ -48,7 +62,7 @@ export interface Signature {
4862
* the association to the Control by setting the `for` attribute to the `id` of the Control
4963
*
5064
* ```gjs
51-
* import { Switch } from 'ember-primitives';
65+
* import { Switch } from 'ember-primitive/components/switchs';
5266
*
5367
* <template>
5468
* <Switch as |s|>
@@ -65,37 +79,42 @@ export interface Signature {
6579

6680
interface ControlSignature {
6781
Element: HTMLInputElement;
68-
Args: { id: string; checked?: boolean; onChange: () => void };
82+
Args: { id: string; checked?: ReturnType<typeof cell<boolean>>; onChange: () => void };
6983
}
7084

7185
const Checkbox: TOC<ControlSignature> = <template>
72-
{{#let (cell @checked) as |checked|}}
73-
<input
74-
id={{@id}}
75-
type="checkbox"
76-
role="switch"
77-
checked={{checked.current}}
78-
aria-checked={{checked.current}}
79-
data-state={{if checked.current "on" "off"}}
80-
{{on "click" (fn toggleWithFallback checked.toggle @onChange)}}
81-
...attributes
82-
/>
83-
{{/let}}
86+
<input
87+
id={{@id}}
88+
type="checkbox"
89+
role="switch"
90+
checked={{@checked.current}}
91+
aria-checked={{@checked.current}}
92+
data-state={{if @checked.current "on" "off"}}
93+
{{on "click" (fn toggleWithFallback @checked.toggle @onChange)}}
94+
...attributes
95+
/>
8496
</template>;
8597

98+
function defaultFalse(value: unknown) {
99+
return value ?? false;
100+
}
101+
86102
/**
87103
* @public
88104
*/
89105
export const Switch: TOC<Signature> = <template>
90106
<div ...attributes data-prim-switch>
91-
{{! @glint-nocheck }}
92107
{{#let (uniqueId) as |id|}}
93-
{{yield
94-
(hash
95-
Control=(component Checkbox checked=@checked id=id onChange=@onChange)
96-
Label=(component Label for=id)
97-
)
98-
}}
108+
{{#let (cell (defaultFalse @checked)) as |checked|}}
109+
{{! @glint-nocheck }}
110+
{{yield
111+
(hash
112+
isChecked=checked.current
113+
Control=(component Checkbox checked=checked id=id onChange=@onChange)
114+
Label=(component Label for=id)
115+
)
116+
}}
117+
{{/let}}
99118
{{/let}}
100119
</div>
101120
</template>;

0 commit comments

Comments
 (0)