Skip to content

Commit 71f2b4e

Browse files
committed
chore: refactor to indeterminate default
1 parent d81b189 commit 71f2b4e

6 files changed

Lines changed: 211 additions & 178 deletions

File tree

2nd-gen/packages/core/components/progress-circle/ProgressCircle.base.ts

Lines changed: 79 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12+
1213
import { PropertyValues } from 'lit';
1314
import { property } from 'lit/decorators.js';
1415

@@ -24,6 +25,14 @@ import {
2425
ProgressCircleStaticColor,
2526
} from './ProgressCircle.types.js';
2627

28+
/**
29+
* @todo SWC-1891 Extract shared progress logic (ARIA, label, clamping, formatting,
30+
* indeterminate derivation) into a `ProgressBase` mixin or abstract class in
31+
* `core/components/progress/` so that both `ProgressCircleBase` and a future
32+
* `ProgressBarBase` can extend it. Also add `formatOptions` support for
33+
* progress-bar's custom value labels (e.g. "3 of 10", "45 MB / 100 MB").
34+
*/
35+
2736
/**
2837
* A progress circle component that visually represents the completion progress of a task.
2938
* Can be used in both determinate (with specific progress value) and indeterminate (loading) states.
@@ -68,39 +77,44 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, {
6877
// SHARED API
6978
// ──────────────────
7079

71-
/**
72-
* @todo Revisit the default API for `indeterminate` and `progress`. SWC-1891
73-
*
74-
* Whether the progress circle shows indeterminate progress (loading state).
75-
*
76-
* When true, displays an animated loading indicator instead of a specific progress value.
77-
*/
78-
@property({ type: Boolean, reflect: true })
79-
public indeterminate = false;
80-
8180
/**
8281
* Accessible label for the progress circle.
8382
*
8483
* Used to provide context about what is loading or progressing.
84+
* When no accessible name is provided (no label, aria-label, or
85+
* aria-labelledby), a default "Loading" label is applied.
86+
*
87+
* @todo Localize the default "Loading" fallback via LanguageResolutionController
88+
* once a runtime i18n system for static strings is available.
8589
*/
8690
@property({ type: String })
8791
public label = '';
8892

8993
/**
9094
* Progress value from 0 to 100.
9195
*
92-
* Only relevant when indeterminate is false. Values outside that range or
93-
* non-finite numbers are clamped to 0–100 (non-finite becomes 0).
96+
* When `null` (indeterminate), the component shows a loading animation.
97+
* Setting a number switches to determinate mode. Removing the `progress`
98+
* attribute or setting this property to `null` returns to indeterminate.
99+
* Values outside 0–100 or non-finite numbers are clamped (non-finite becomes 0).
100+
*
101+
* Reflected to the `progress` attribute when set; the attribute is omitted when indeterminate.
94102
*/
95-
@property({ type: Number })
96-
public progress = 0;
103+
@property({ type: Number, reflect: true })
104+
public progress: number | null = null;
97105

98106
private languageResolver = new LanguageResolutionController(this);
99107

100108
// ──────────────────────
101109
// IMPLEMENTATION
102110
// ──────────────────────
103111

112+
/**
113+
* @todo Localize via LanguageResolutionController once a runtime i18n
114+
* system for static strings is available.
115+
*/
116+
private static readonly DEFAULT_LABEL = 'Loading';
117+
104118
/** True when light DOM has element nodes or non-whitespace text (no default slot). */
105119
private static hasMeaningfulLightDomChildren(host: HTMLElement): boolean {
106120
for (const node of host.childNodes) {
@@ -114,6 +128,28 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, {
114128
return false;
115129
}
116130

131+
private hasAccessibleName(): boolean {
132+
return Boolean(
133+
this.label ||
134+
this.getAttribute('aria-label') ||
135+
this.getAttribute('aria-labelledby')
136+
);
137+
}
138+
139+
private static clampProgress(value: number): number {
140+
if (!Number.isFinite(value)) {
141+
return 0;
142+
}
143+
return Math.min(100, Math.max(0, value));
144+
}
145+
146+
private formatProgress(): string {
147+
return new Intl.NumberFormat(this.languageResolver.language, {
148+
style: 'percent',
149+
unitDisplay: 'narrow',
150+
}).format((this.progress ?? 0) / 100);
151+
}
152+
117153
private warnDeprecatedLightDomChildren(): void {
118154
if (!window.__swc?.DEBUG) {
119155
return;
@@ -129,15 +165,27 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, {
129165
);
130166
}
131167

132-
private static clampProgress(value: number): number {
133-
if (!Number.isFinite(value)) {
134-
return 0;
168+
private warnMissingAccessibleName(): void {
169+
if (!window.__swc?.DEBUG) {
170+
return;
135171
}
136-
return Math.min(100, Math.max(0, value));
172+
window.__swc?.warn(
173+
this,
174+
`<${this.localName}> requires an accessible name. A default label of "${ProgressCircleBase.DEFAULT_LABEL}" has been applied, but a more specific label should be provided via:`,
175+
'https://opensource.adobe.com/spectrum-web-components/second-gen/?path=/docs/components-progress-circle--docs',
176+
{
177+
type: 'accessibility',
178+
issues: [
179+
'value supplied to the "label" attribute, which will be displayed visually as part of the element, or',
180+
'value supplied to the "aria-label" attribute, which will only be provided to screen readers, or',
181+
'an element ID reference supplied to the "aria-labelledby" attribute, which will be provided by screen readers and will need to be managed manually by the parent application.',
182+
],
183+
}
184+
);
137185
}
138186

139187
protected override willUpdate(changes: PropertyValues): void {
140-
if (changes.has('progress')) {
188+
if (changes.has('progress') && this.progress !== null) {
141189
const clamped = ProgressCircleBase.clampProgress(this.progress);
142190
if (clamped !== this.progress) {
143191
this.progress = clamped;
@@ -148,22 +196,14 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, {
148196

149197
protected override firstUpdated(changes: PropertyValues): void {
150198
super.firstUpdated(changes);
151-
if (!this.hasAttribute('role')) {
152-
this.setAttribute('role', 'progressbar');
153-
}
154-
}
155-
156-
private formatProgress(): string {
157-
return new Intl.NumberFormat(this.languageResolver.language, {
158-
style: 'percent',
159-
unitDisplay: 'narrow',
160-
}).format(this.progress / 100);
199+
this.setAttribute('role', 'progressbar');
161200
}
162201

163202
protected override updated(changes: PropertyValues): void {
164203
super.updated(changes);
165-
if (changes.has('indeterminate')) {
166-
if (this.indeterminate) {
204+
205+
if (changes.has('progress')) {
206+
if (this.progress === null) {
167207
this.removeAttribute('aria-valuemin');
168208
this.removeAttribute('aria-valuemax');
169209
this.removeAttribute('aria-valuenow');
@@ -175,13 +215,11 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, {
175215
this.setAttribute('aria-valuetext', this.formatProgress());
176216
}
177217
}
178-
if (!this.indeterminate && changes.has('progress')) {
179-
this.setAttribute('aria-valuenow', String(this.progress));
180-
this.setAttribute('aria-valuetext', this.formatProgress());
181-
}
182-
if (!this.indeterminate && changes.has(languageResolverUpdatedSymbol)) {
218+
219+
if (this.progress !== null && changes.has(languageResolverUpdatedSymbol)) {
183220
this.setAttribute('aria-valuetext', this.formatProgress());
184221
}
222+
185223
if (changes.has('label')) {
186224
if (this.label.length) {
187225
this.setAttribute('aria-label', this.label);
@@ -190,31 +228,14 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, {
190228
}
191229
}
192230

193-
const hasAccessibleName = (): boolean => {
194-
return Boolean(
195-
this.label ||
196-
this.getAttribute('aria-label') ||
197-
this.getAttribute('aria-labelledby')
198-
);
199-
};
231+
// Apply default accessible name fallback after handling explicit label changes.
232+
if (changes.has('label') && !this.hasAccessibleName()) {
233+
this.setAttribute('aria-label', ProgressCircleBase.DEFAULT_LABEL);
234+
this.warnMissingAccessibleName();
235+
}
200236

201237
if (window.__swc?.DEBUG) {
202238
this.warnDeprecatedLightDomChildren();
203-
if (!hasAccessibleName() && this.getAttribute('role') === 'progressbar') {
204-
window.__swc?.warn(
205-
this,
206-
`<${this.localName}> elements need one of the following to be accessible:`,
207-
'https://opensource.adobe.com/spectrum-web-components/second-gen/?path=/docs/components-progress-circle--docs',
208-
{
209-
type: 'accessibility',
210-
issues: [
211-
'value supplied to the "label" attribute, which will be displayed visually as part of the element, or',
212-
'value supplied to the "aria-label" attribute, which will only be provided to screen readers, or',
213-
'an element ID reference supplied to the "aria-labelledby" attribute, which will be provided by screen readers and will need to be managed manually by the parent application.',
214-
],
215-
}
216-
);
217-
}
218239
}
219240
}
220241
}

2nd-gen/packages/core/components/progress-circle/ProgressCircle.types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ export const PROGRESS_CIRCLE_VALID_SIZES = [
1616
's',
1717
'm',
1818
'l',
19-
] as const satisfies ElementSize[];
19+
] as const satisfies readonly ElementSize[];
2020
export const PROGRESS_CIRCLE_STATIC_COLORS = ['white', 'black'] as const;
2121

2222
export type ProgressCircleStaticColor =
2323
(typeof PROGRESS_CIRCLE_STATIC_COLORS)[number];
24+
export type ProgressCircleSize = (typeof PROGRESS_CIRCLE_VALID_SIZES)[number];

2nd-gen/packages/swc/.storybook/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ const preview = {
179179
},
180180
source: {
181181
excludeDecorators: true,
182-
type: 'auto',
182+
type: 'dynamic',
183183
language: 'html',
184184
transform: async (source: string) => {
185185
try {

2nd-gen/packages/swc/components/progress-circle/ProgressCircle.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { CSSResultArray, html, TemplateResult } from 'lit';
1414
import { property } from 'lit/decorators.js';
1515
import { classMap } from 'lit/directives/class-map.js';
16+
import { ifDefined } from 'lit/directives/if-defined.js';
1617

1718
import {
1819
PROGRESS_CIRCLE_STATIC_COLORS,
@@ -25,25 +26,23 @@ import styles from './progress-circle.css';
2526

2627
/**
2728
* Progress circles show the progression of a system operation such as downloading, uploading, processing, etc. in a visual way.
29+
*
2830
* They can represent determinate (with a specific progress value) or indeterminate (loading) progress.
2931
*
3032
* @element swc-progress-circle
3133
* @status preview
3234
* @since 0.0.1
3335
*
3436
* @property {string} staticColor - Reflected as the `static-color` attribute. Static color variant for use on different backgrounds.
35-
* @property {number} progress - Progress value between 0 and 100.
36-
* @property {boolean} indeterminate - Indeterminate state for loading.
37+
* @property {number | null} progress - Progress between 0 and 100, reflected as the `progress` attribute when set. When `null` (indeterminate), the attribute is omitted.
3738
* @property {string} size - Size of the component.
3839
* @property {string} label - Label for the component.
3940
*
4041
* @example
4142
* <swc-progress-circle progress="75" label="Loading progress"></swc-progress-circle>
4243
*
4344
* @example
44-
* <swc-progress-circle indeterminate label="Loading..."></swc-progress-circle>
45-
*
46-
* Light DOM children are not projected into the shadow tree. Use the `label` attribute or property, or `aria-label` / `aria-labelledby` on the host, for an accessible name.
45+
* <swc-progress-circle label="Loading..."></swc-progress-circle>
4746
*/
4847
export class ProgressCircle extends ProgressCircleBase {
4948
// ────────────────────
@@ -73,26 +72,37 @@ export class ProgressCircle extends ProgressCircleBase {
7372
return [styles];
7473
}
7574

75+
/**
76+
* Compute the SVG stroke-dashoffset for the fill circle.
77+
*
78+
* - **Indeterminate** (`progress` is `null`): returns `undefined` so CSS
79+
* animation keyframes fully control the offset.
80+
* - **0%**: returns 98 instead of 100. A dashoffset of 100 hides the fill
81+
* entirely, which fails WCAG 1.4.11 non-text contrast (the track alone
82+
* may not meet 3:1 against the background). The 2-unit fill keeps the
83+
* graphical element perceivable. `aria-valuenow` stays at 0.
84+
* - **1–100%**: returns `100 - progress`.
85+
*/
86+
private computeDashOffset(): number | undefined {
87+
if (this.progress === null) {
88+
return undefined;
89+
}
90+
if (this.progress === 0) {
91+
return 98;
92+
}
93+
return 100 - this.progress;
94+
}
95+
7696
protected override render(): TemplateResult {
7797
const strokeWidth = this.size === 's' ? 2 : this.size === 'l' ? 6 : 4;
7898
// SVG strokes are centered, so subtract half the stroke width from the radius to create an inner stroke.
7999
const radius = `calc(50% - ${strokeWidth / 2}px)`;
80100

81-
// At progress=0, a dashoffset of 100 makes the fill fully invisible, which fails WCAG 1.4.11
82-
// non-text contrast (the track alone may not meet 3:1 against the background).
83-
// Clamp to 98 to show a minimum 2-unit fill so the graphical element remains perceivable.
84-
// aria-valuenow stays at 0 — this is a visual-only adjustment.
85-
const dashOffset = this.indeterminate
86-
? 100 - this.progress
87-
: this.progress === 0
88-
? 98
89-
: 100 - this.progress;
90-
91101
return html`
92102
<div
93103
class=${classMap({
94104
['swc-ProgressCircle']: true,
95-
['swc-ProgressCircle--indeterminate']: this.indeterminate,
105+
['swc-ProgressCircle--indeterminate']: this.progress === null,
96106
[`swc-ProgressCircle--static${capitalize(this.staticColor)}`]:
97107
typeof this.staticColor !== 'undefined',
98108
})}
@@ -117,7 +127,7 @@ export class ProgressCircle extends ProgressCircleBase {
117127
class="swc-ProgressCircle-fill"
118128
pathLength="100"
119129
stroke-dasharray="100 200"
120-
stroke-dashoffset=${dashOffset}
130+
stroke-dashoffset=${ifDefined(this.computeDashOffset())}
121131
stroke-linecap="round"
122132
/>
123133
</svg>

0 commit comments

Comments
 (0)