Skip to content

Commit 8b4871f

Browse files
srtaalejzimeg
andauthored
feat: add accessible flag and environment variable for screen reader friendly forms (#455)
Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent 75d2e0d commit 8b4871f

7 files changed

Lines changed: 149 additions & 6 deletions

File tree

cmd/root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ func InitConfig(ctx context.Context, clients *shared.ClientFactory, rootCmd *cob
247247
clients.Config.SystemConfig.SetCustomConfigDirPath(clients.Config.ConfigDirFlag)
248248
}
249249

250+
// Accessible mode implies no-color
251+
if clients.Config.AccessibleFlag {
252+
clients.Config.NoColor = true
253+
}
254+
250255
// Init color and formatting
251256
style.ToggleStyles(clients.IO.IsTTY() && !clients.Config.NoColor)
252257
style.ToggleSpinner(clients.IO.IsTTY() && !clients.Config.NoColor && !clients.Config.DebugEnabled)

internal/config/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
)
2323

2424
// Environment Variable constants
25+
const slackAccessibleEnv = "ACCESSIBLE"
2526
const slackAutoRequestAAAEnv = "SLACK_AUTO_REQUEST_AAA"
2627
const slackConfigDirEnv = "SLACK_CONFIG_DIR"
2728
const slackDisableTelemetryEnv = "SLACK_DISABLE_TELEMETRY"
@@ -38,6 +39,7 @@ type Config struct {
3839
// Raw flags (for metrics)
3940
RawFlags []string
4041
// Command flags
42+
AccessibleFlag bool
4143
APIHostFlag string
4244
APIHostResolved string
4345
AppFlag string
@@ -50,6 +52,7 @@ type Config struct {
5052
DisableTelemetryFlag bool
5153
ForceFlag bool
5254
LogstashHostResolved string
55+
NoColor bool
5356
RuntimeFlag string
5457
RuntimeName string
5558
RuntimeVersion string
@@ -58,7 +61,6 @@ type Config struct {
5861
SlackTestTraceFlag bool
5962
TeamFlag string
6063
TokenFlag string
61-
NoColor bool
6264

6365
// Feature experiments
6466
ExperimentsFlag []string

internal/config/dotenv.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ func (c *Config) LoadEnvironmentVariables() error {
2727
return nil
2828
}
2929

30+
// Load accessible mode from environment variables
31+
var accessible = strings.TrimSpace(c.os.Getenv(slackAccessibleEnv))
32+
if accessible != "" && accessible != "false" && accessible != "0" {
33+
c.AccessibleFlag = true
34+
}
35+
3036
// Load slackTestTraceFlag from environment variables
3137
var testTrace = strings.TrimSpace(c.os.Getenv(slackTestTraceEnv))
3238
if testTrace != "" && testTrace != "false" && testTrace != "0" {

internal/config/dotenv_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,41 @@ func Test_DotEnv_LoadEnvironmentVariables(t *testing.T) {
167167
assert.Equal(t, "", cfg.ConfigDirFlag)
168168
},
169169
},
170+
"ACCESSIBLE=true should set Accessible to true": {
171+
envName: "ACCESSIBLE",
172+
envValue: "true",
173+
assertOnConfig: func(t *testing.T, cfg *Config) {
174+
assert.Equal(t, true, cfg.AccessibleFlag)
175+
},
176+
},
177+
"ACCESSIBLE=1 should set Accessible to true": {
178+
envName: "ACCESSIBLE",
179+
envValue: "1",
180+
assertOnConfig: func(t *testing.T, cfg *Config) {
181+
assert.Equal(t, true, cfg.AccessibleFlag)
182+
},
183+
},
184+
"ACCESSIBLE=false should set Accessible to false": {
185+
envName: "ACCESSIBLE",
186+
envValue: "false",
187+
assertOnConfig: func(t *testing.T, cfg *Config) {
188+
assert.Equal(t, false, cfg.AccessibleFlag)
189+
},
190+
},
191+
"ACCESSIBLE=0 should set Accessible to false": {
192+
envName: "ACCESSIBLE",
193+
envValue: "0",
194+
assertOnConfig: func(t *testing.T, cfg *Config) {
195+
assert.Equal(t, false, cfg.AccessibleFlag)
196+
},
197+
},
198+
"empty ACCESSIBLE should set Accessible to false": {
199+
envName: "ACCESSIBLE",
200+
envValue: "",
201+
assertOnConfig: func(t *testing.T, cfg *Config) {
202+
assert.Equal(t, false, cfg.AccessibleFlag)
203+
},
204+
},
170205
}
171206

172207
for name, tc := range tableTests {

internal/config/flags.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,23 @@ func (c *Config) SetFlags(cmd *cobra.Command) {
3030

3131
// InitializeGlobalFlags configures flags and creates links from cmd to config
3232
func (c *Config) InitializeGlobalFlags(cmd *cobra.Command) {
33+
cmd.PersistentFlags().BoolVarP(&c.AccessibleFlag, "accessible", "", false, "use accessible prompts for screen readers")
3334
cmd.PersistentFlags().StringVar(&c.APIHostFlag, "apihost", "", "Slack API host")
3435
cmd.PersistentFlags().StringVarP(&c.AppFlag, "app", "a", "", "use a specific app ID or environment")
3536
cmd.PersistentFlags().StringVarP(&c.ConfigDirFlag, "config-dir", "", "", "use a custom path for system config directory")
3637
cmd.PersistentFlags().BoolVarP(&c.DeprecatedDevAppFlag, "local-run", "l", false, "use the local run app created by the `run` command") // deprecated
3738
cmd.PersistentFlags().BoolVarP(&c.DeprecatedDevFlag, "dev", "d", false, "use dev apis") // Can be removed after v0.25.0
38-
cmd.PersistentFlags().StringVarP(&c.DeprecatedWorkspaceFlag, "workspace", "", "", "select workspace or organization by domain name or team ID")
3939
cmd.PersistentFlags().StringSliceVarP(&c.ExperimentsFlag, "experiment", "e", nil, "use the experiment(s) in the command")
4040
cmd.PersistentFlags().BoolVarP(&c.ForceFlag, "force", "f", false, "ignore warnings and continue executing command")
4141
cmd.PersistentFlags().BoolVarP(&c.NoColor, "no-color", "", false, "remove styles and formatting from outputs")
42+
cmd.PersistentFlags().StringVarP(&c.RuntimeFlag, "runtime", "r", "", "the project's runtime language:\n deno (default), deno1.1, deno1.x, etc")
4243
cmd.PersistentFlags().BoolVarP(&c.SkipUpdateFlag, "skip-update", "s", false, "skip checking for latest version of CLI")
4344
cmd.PersistentFlags().BoolVarP(&c.SlackDevFlag, "slackdev", "", false, "shorthand for --apihost=https://dev.slack.com")
44-
cmd.PersistentFlags().StringVarP(&c.RuntimeFlag, "runtime", "r", "", "the project's runtime language:\n deno (default), deno1.1, deno1.x, etc")
4545
// TODO - next semver MAJOR can consider a new shorthand flag, right now -t and -T are used by other commands
4646
cmd.PersistentFlags().StringVarP(&c.TeamFlag, "team", "w", "", "select workspace or organization by team name or ID")
4747
cmd.PersistentFlags().StringVarP(&c.TokenFlag, "token", "", "", "set the access token associated with a team")
4848
cmd.PersistentFlags().BoolVarP(&c.DebugEnabled, "verbose", "v", false, "print debug logging and additional info")
49+
cmd.PersistentFlags().StringVarP(&c.DeprecatedWorkspaceFlag, "workspace", "", "", "select workspace or organization by domain name or team ID")
4950

5051
cmd.PersistentFlags().Lookup("apihost").Hidden = true
5152
cmd.PersistentFlags().Lookup("dev").Hidden = true

internal/iostreams/forms.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ package iostreams
2121
import (
2222
"context"
2323
"errors"
24+
"fmt"
2425
"slices"
26+
"strings"
2527

2628
huh "charm.land/huh/v2"
2729
"github.com/slackapi/slack-cli/internal/experiment"
@@ -39,13 +41,24 @@ func newForm(io *IOStreams, field huh.Field) *huh.Form {
3941
} else {
4042
form = form.WithTheme(style.ThemeSurvey())
4143
}
44+
if io != nil && io.config.AccessibleFlag {
45+
form = form.WithAccessible(true)
46+
}
4247
return form
4348
}
4449

4550
// buildInputForm constructs an interactive form for text input prompts.
4651
func buildInputForm(io *IOStreams, message string, cfg InputPromptConfig, input *string) *huh.Form {
52+
title := message
53+
if io != nil && io.config.AccessibleFlag {
54+
if cfg.Placeholder != "" {
55+
title = fmt.Sprintf("%s (default: %s):", strings.TrimSuffix(message, ":"), cfg.Placeholder)
56+
} else if !strings.HasSuffix(message, ":") {
57+
title = message + ":"
58+
}
59+
}
4760
field := huh.NewInput().
48-
Title(message).
61+
Title(title).
4962
Prompt(style.Chevron() + " ").
5063
Placeholder(cfg.Placeholder).
5164
Value(input)
@@ -100,8 +113,13 @@ func buildSelectForm(io *IOStreams, msg string, options []string, cfg SelectProm
100113
opts = append(opts, huh.NewOption(key, opt))
101114
}
102115

116+
title := msg
117+
if io != nil && io.config.AccessibleFlag && len(options) > 0 {
118+
title = fmt.Sprintf("%s (press Enter for 1):", strings.TrimSuffix(msg, ":"))
119+
}
120+
103121
field := huh.NewSelect[string]().
104-
Title(msg).
122+
Title(title).
105123
Description(cfg.Help).
106124
Options(opts...).
107125
Value(selected)
@@ -125,8 +143,12 @@ func selectForm(io *IOStreams, _ context.Context, msg string, options []string,
125143

126144
// buildPasswordForm constructs an interactive form for password (hidden input) prompts.
127145
func buildPasswordForm(io *IOStreams, message string, cfg PasswordPromptConfig, input *string) *huh.Form {
146+
title := message
147+
if io != nil && io.config.AccessibleFlag && !strings.HasSuffix(message, ":") {
148+
title = message + ":"
149+
}
128150
field := huh.NewInput().
129-
Title(message).
151+
Title(title).
130152
Prompt(style.Chevron() + " ").
131153
EchoMode(huh.EchoModePassword).
132154
Value(input)

internal/iostreams/forms_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,78 @@ func TestFormsUseSlackTheme(t *testing.T) {
415415
})
416416
}
417417

418+
func TestFormsAccessible(t *testing.T) {
419+
fsMock := slackdeps.NewFsMock()
420+
osMock := slackdeps.NewOsMock()
421+
osMock.AddDefaultMocks()
422+
cfg := config.NewConfig(fsMock, osMock)
423+
cfg.AccessibleFlag = true
424+
io := NewIOStreams(cfg, fsMock, osMock)
425+
426+
t.Run("select form accepts valid numbered input", func(t *testing.T) {
427+
var selected string
428+
f := buildSelectForm(io, "Pick one", []string{"A", "B", "C"}, SelectPromptConfig{}, &selected)
429+
430+
var out strings.Builder
431+
err := f.WithOutput(&out).WithInput(strings.NewReader("2\n")).Run()
432+
433+
assert.NoError(t, err)
434+
assert.Equal(t, "B", selected)
435+
assert.Contains(t, out.String(), "1. A")
436+
assert.Contains(t, out.String(), "2. B")
437+
assert.Contains(t, out.String(), "3. C")
438+
assert.Contains(t, out.String(), "Enter a number between 1 and 3")
439+
})
440+
441+
t.Run("select form shows default hint in accessible mode", func(t *testing.T) {
442+
var selected string
443+
f := buildSelectForm(io, "Pick one", []string{"Alpha", "Beta"}, SelectPromptConfig{}, &selected)
444+
445+
var out strings.Builder
446+
err := f.WithOutput(&out).WithInput(strings.NewReader("\n")).Run()
447+
448+
assert.NoError(t, err)
449+
assert.Equal(t, "Alpha", selected)
450+
assert.Contains(t, out.String(), "Pick one (press Enter for 1)")
451+
})
452+
453+
t.Run("confirm form accepts yes/no input", func(t *testing.T) {
454+
var choice bool
455+
f := buildConfirmForm(io, "Continue?", &choice)
456+
457+
var out strings.Builder
458+
err := f.WithOutput(&out).WithInput(strings.NewReader("y\n")).Run()
459+
460+
assert.NoError(t, err)
461+
assert.True(t, choice)
462+
assert.Contains(t, out.String(), "Continue?")
463+
})
464+
465+
t.Run("input form accepts text input", func(t *testing.T) {
466+
var input string
467+
f := buildInputForm(io, "Name?", InputPromptConfig{}, &input)
468+
469+
var out strings.Builder
470+
err := f.WithOutput(&out).WithInput(strings.NewReader("my-app\n")).Run()
471+
472+
assert.NoError(t, err)
473+
assert.Equal(t, "my-app", input)
474+
assert.Contains(t, out.String(), "Name?")
475+
})
476+
477+
t.Run("input form shows default placeholder in accessible mode", func(t *testing.T) {
478+
var input string
479+
f := buildInputForm(io, "Name your app:", InputPromptConfig{Placeholder: "cool-app-123"}, &input)
480+
481+
var out strings.Builder
482+
err := f.WithOutput(&out).WithInput(strings.NewReader("\n")).Run()
483+
484+
assert.NoError(t, err)
485+
assert.Equal(t, "", input)
486+
assert.Contains(t, out.String(), "Name your app (default: cool-app-123):")
487+
})
488+
}
489+
418490
func TestFormsNoColor(t *testing.T) {
419491
t.Run("forms use plain theme with no-color", func(t *testing.T) {
420492
fsMock := slackdeps.NewFsMock()

0 commit comments

Comments
 (0)